智谱 Agent 开发一面:20 问完整回答(从 0 到 1 + 工具工程 + RAG + 手撕 ReAct)
题目来源:小红书笔记「智谱 Agent 开发一面 面完当天约二面」(作者 周也在coding)。面试结构是 40 分钟项目 + 20 分钟手撕,每个回答后都会追问“为什么这么选”。
使用说明:Q1-Q10 以一个「内部运维排障 Agent」作为示例项目线作答,讲的是可复用的工程思路;面试时把场景、工具数量、指标替换成你自己的真实项目。Q19-Q20 给出可直接运行的 Python 代码。
答题原则:每个选择都给“为什么”,每个方案都给“边界和兜底”。这场面试的区分点不在“会不会用框架”,而在“你能不能解释清楚为什么这么选”。
一、项目与架构取舍(Q1-Q4)
Q1. 先讲一个你自己从零做的 Agent,别讲架构图,讲你第一天写了什么。
一句话答案:第一天不写“智能”,写“最小可运行闭环”:一个入口、一个模型适配层、一个工具注册表、一个 ReAct 循环、一份 trace、一个真实工具、十条 smoke case。目标不是效果多好,而是“能跑、能看、能失败、能回放”。
展开回答:
我做的场景是内部运维排障 Agent:值班同学收到一条告警,Agent 去查告警详情、日志、指标、发布记录、依赖关系,最后输出“可能原因 + 证据 + 建议动作”,高危操作不直接执行,只给建议。
第一天我是按这个顺序写的:
| 顺序 | 做什么 | 产出 |
|---|---|---|
| 1 | 定义输入输出契约 | 输入是告警 ID 或自然语言描述;输出是 summary / root_cause_candidates / evidence / recommended_actions / confidence / need_human |
| 2 | 定义 Tool 接口 | name / description / JSON Schema / execute(args) / timeout / error type,所有工具走统一注册表 |
| 3 | 写循环骨架 | while step < max_steps:调模型 → 拿 tool_call → 执行工具 → 把结果作为 observation 回填 → 再调模型 |
| 4 | 接第一个真实工具 | 告警详情 API,用一条真实告警跑通,而不是先做 UI |
| 5 | 加 trace | 每一步记录 prompt、候选工具、模型输出、工具参数、执行结果、耗时、Token,落 JSONL,能按 trace_id 回放 |
| 6 | 加失败兜底 | 工具抛异常转成 observation;未知工具给提示;参数不合法打回;达到最大步数强制收敛 |
| 7 | 写 10 条 smoke case | 人工看结果,先保证“不崩、不乱编、能给出证据” |
第一版我故意不做:多 Agent、长期记忆、复杂 RAG、前端界面。因为这些东西会掩盖最核心的问题——循环是否可控、工具是否可靠、失败是否可观测。
第一天遇到的第一个坑是:模型输出的工具参数类型不对,工具直接抛异常,整个循环就断了。所以我当天就把“工具异常”统一转成一条结构化的 observation,让模型看到错误后自己换参数或换工具,而不是让程序崩掉。
为什么这么选:先把“可运行、可观测、可失败”的底座做出来,后面调 Prompt、加工具、换模型才有基线;否则你根本不知道效果变好是因为模型,还是因为运气。
Q2. 这个场景为什么非得用 Agent,写死流程不行吗?
一句话答案:因为这个场景“下一步查什么”依赖上一步的观测结果,分支不可枚举;固定流程只能覆盖已知故障,长尾和新故障必须改代码。但高危动作仍然写死,不让 Agent 自由发挥。
展开回答:
一条告警进来,根因可能在发布、依赖、数据库、配置、流量、缓存,任何一个方向。固定流程只能写成:
告警 → 查日志 → 查发布 → 输出模板但真实排障是:
告警 → 查日志 → 发现下游 timeout → 去查依赖服务
→ 发现依赖服务刚发布 → 去查发布记录
→ 发现是配置变更 → 去查配置中心下一步取决于上一步看到了什么,这就是 Agent 的价值:它可以根据 observation 动态选择下一个工具,而不是沿着一条预先写死的路径走。新增一个工具,Agent 不需要改流程代码,只要把工具注册进去;固定流程每加一个分支就要改代码、发版。
但我也不会把整个场景都交给 Agent:
- 已知告警类型 → 走固定 runbook,快、稳、便宜;
- 未知/复杂告警 → Agent 兜底;
- 重启、回滚、摘流量这类高危动作 → 必须人工确认,Agent 只给建议,不直接执行。
加分句:Agent 适合“路径不确定、但结果可复核”的场景;不适合“路径确定”或“后果不可逆”的场景。
Q3. 你试过写死吗?差在哪?
一句话答案:试过,第一版就是固定流水线。它覆盖了大概 60% 的常见告警,但差在三点:不会根据结果换路径、不会补查、新故障要发版。
展开回答:
第一版固定流程是:
告警详情 → 按关键词查日志 → 查最近发布记录 → 套模板输出它在常见场景确实好用:P95 大概 3 秒,成本低,结果确定,测试也好写。但遇到这几类就露馅:
- 关键词没命中就结束。固定流程查
ERROR没查到,就认为没问题;Agent 会换关键词、扩大时间窗、查下游调用链。 - 根因在下游,流程没有这个分支。日志里写的是
downstream timeout,固定流程不会去查依赖服务;Agent 会顺着这条线索继续查。 - 工具返回空或异常,流程直接报错。Agent 会把“空结果”当成一条 observation,换个工具再试。
- 新告警类型要改代码发版。每加一种故障模式就要加分支,维护成本随分支数量爆炸。
所以最后的架构是混合:规则路由先判断是不是已知告警,已知的走固定 runbook;未知的、或者 runbook 执行失败的,交给 Agent 兜底。Agent 也可以把 runbook 当成一个工具来调用。
为什么这么选:固定流程在确定性场景下更快、更便宜、更可控,没理由为了“用 Agent”而放弃它。Agent 是长尾兜底,不是万能替代。
Q4. 那你怎么判断一个任务该给 Agent 还是该写死?
一句话答案:用四个维度判断——路径确定性、输入开放性、风险等级、变更频率;默认写死,只有“不确定性带来的收益”大于“协调成本”时才上 Agent。
展开回答:
| 维度 | 更适合写死 | 更适合 Agent |
|---|---|---|
| 路径确定性 | 分支可枚举、SOP 固定 | 分支不可枚举、依赖中间结果 |
| 输入开放性 | 结构化参数、字段固定 | 自然语言、模糊描述、多模态 |
| 风险等级 | 高危、不可逆、强合规 | 可复核、可回滚、有人工确认 |
| 变更频率 | 流程稳定、工具少 | 工具/流程频繁增加 |
| 成本时延 | 高频、低延迟、成本敏感 | 低频、复杂、可接受多步调用 |
落到具体判断,我会问自己四个问题:
- 这件事能不能用三个
if-else加一个模板覆盖?能,就写死。 - 下一步是否依赖上一步的返回内容?不依赖,就写死。
- 错了能不能复核/回滚?不能,就写死加人工,不要给 Agent 自主权。
- 工具会不会持续增加?不会,就写死;会,就用 Agent + 工具注册表。
最常见的落地形态是混合架构:外层是固定流程,负责权限、风控、审计、兜底;内层把“不确定的节点”交给 Agent。这样既保留确定性,又能处理长尾。
加分句:Agent 是处理不确定性的手段,不是目的。能用流程解决的,就不要用 Agent。
二、工具工程(Q5-Q10)
Q5. 工具是你自己写的还是接的现成的?
一句话答案:混合。能接现成的就接现成的;现成的不满足契约、权限或审计要求的,自己包一层适配器。不重复造轮子,也不把裸 API 直接塞给模型。
展开回答:
我的工具来源大概分三类:
- 现成 API 包装:告警平台、日志平台、Prometheus、发布系统、CMDB、工单系统、知识库检索。这些系统本身有 API,我写一层薄适配器。
- 现成 MCP Server:文件系统、GitHub、数据库查询这类通用能力,直接接现成的 MCP Server,不自己重写。
- 自己写的业务工具:把内部系统里“面向人”的接口,改造成“面向模型”的工具,比如把分页、字段裁剪、错误归一化、脱敏都封装掉。
自己包一层的原因,不是因为喜欢写代码,而是因为裸 API 直接给模型会有这些问题:
- 鉴权分散,每个工具都要重复处理 Token;
- 返回字段太多,塞进上下文又贵又噪;
- 错误信息是给开发看的,模型看不懂,无法根据错误换策略;
- 没有超时、重试、分页、幂等和审计;
- 高危操作没有二次确认。
所以我们会有一个统一的 Tool 网关/注册表:每个工具有 name、description、JSON Schema、owner、版本、SLA、危险等级、权限范围。Agent 只认注册表,不直接认某个 SDK。
加分句:工具不是“ API 的裸包装”,而是“给模型用的产品”。面向人的接口和面向模型的工具,设计目标不一样。
Q6. 工具描述你怎么写的?一个工具写多少字?
一句话答案:工具描述是“给模型看的 API 文档 + 选择指南”,不是越短越好。常规工具 80-200 字,复杂工具 300 字左右,控制在 400 Token 以内;核心是写清“什么时候用、什么时候不用、参数怎么填、失败长什么样”。
展开回答:
我用的模板是:
name: query_logs
summary: 按服务、时间窗和关键词查询实时日志,返回命中的日志片段。
when_to_use: 需要定位错误码、异常堆栈、超时、下游调用失败时。
when_not_to_use: 查历史归档日志用 query_archive_logs;查指标趋势用 query_metrics。
parameters:
service: string,必填,服务名,例如 order-service
start: string,必填,ISO8601 时间
end: string,必填,ISO8601 时间,时间窗不超过 2 小时
keyword: string,可选,支持 error、timeout、orderId
returns: 日志片段列表,每条含 timestamp、level、message、trace_id
errors: 服务不存在、时间窗过大、无权限
example: query_logs(service="order-service", start="2026-11-03T10:00:00", end="2026-11-03T10:30:00", keyword="timeout")几个关键点:
- 名字用动词 + 对象,比如
query_logs、get_deploy_history,不要用log_tool这种模糊名字。 - 必须写“什么时候不用”。只写“什么时候用”,模型会把相似工具乱选;写清边界,误选会明显下降。
- 参数给类型、必填、枚举、示例。能枚举就不要让模型自由生成,比如环境用
dev/staging/prod,不要让它猜。 - 错误要可读。不要返回
HTTP 500就完事,要告诉模型“服务名不存在,请检查 service 参数”。 - 长度控制。太短无法区分边界,太长会稀释注意力、增加 Token。30 个工具时,描述总量本身就会占掉几千 Token。
我不会按“一个工具必须写多少字”来定,而是按“能不能和相邻工具区分开”来定。写完用工具选择评测集测混淆矩阵,哪个工具经常被误选,就改哪个的描述。
加分句:工具描述不是文档,是 Prompt 的一部分;它直接决定模型选不选你。
Q7. 工具从 5 个涨到 30 个之后,模型选错的概率有没有变高?
一句话答案:会变高,尤其是工具名相似、描述重叠、参数边界模糊的时候;但不是线性上升,靠分组、路由和工具检索可以把准确率拉回来。
展开回答:
变高的原因有四个:
- 候选过载:5 个工具时模型很容易比较;30 个工具时注意力被稀释,容易选“看起来差不多”的。
- Schema Token 膨胀:30 个工具、每个 150-300 Token,光工具定义就 4500-9000 Token,既贵又挤占上下文。
- 能力重叠:
query_logs和query_archive_logs、get_deploy_history和get_change_records,名字和描述都像,模型必然混。 - 参数歧义:同一个字段在不同工具里含义不同,比如
service有的指服务名,有的指业务线。
我们当时的体感是:5 个工具时选择准确率很高,涨到 30 个之后,误选主要集中在上面那几对相似工具。后来做了四件事拉回来:
- 合并/重命名:能合并的合并,不能合并的把名字和描述拉开,比如
query_realtime_logs和query_archived_logs。 - 两级路由:先按领域分类(基础设施 / 业务 / 发布 / 数据),再只给该领域的 5-8 个工具。
- 工具检索:把工具描述做 Embedding,按用户 Query 召回 Top 5-8,再注入
tools字段。 - 描述边界:每个工具补
when_not_to_use,把“不要选我”的话写清楚。
具体数字(示例,换成你的真实数据):5 个工具时选择准确率 96%,30 个工具时掉到 84%;做完分组 + 工具检索后回到 92%。这个数字要来自评测集,不要拍脑袋。
加分句:工具不是越多越好。30 个工具的正确形态是“分层 + 路由 + 按需注入”,不是“一次性全塞给模型”。
Q8. 选错了你从哪发现的?日志里看得出来吗?
一句话答案:看得出来,但必须是结构化 trace,不能只看最终回答。硬错误看异常,软错误看重复调用、空结果、无证据和用户纠正。
展开回答:
每一步我会记录这些字段:
trace_id, step, model, prompt_version, tools_version,
candidate_tools, tool_name, arguments, schema_valid,
tool_ok, result_summary, latency_ms, token_in, token_out,
model_reason, next_action选错工具会在三个层面暴露:
第一,硬错误。 未知工具、参数 Schema 不合法、无权限、超时。这类日志一眼能看出来。
第二,软错误。 工具调用成功了,但结果和问题无关;或者模型反复调同一个工具;或者最终答案没有引用任何证据;或者用户重新描述问题、人工接管。这类日志要配合指标看。
第三,离线评测。 标注每条 Query 的期望工具序列,算工具选择准确率、步骤数、任务成功率,做混淆矩阵。哪个工具经常被误选,一目了然。
日志要能回放:Prompt 版本、工具 Schema 版本、候选工具列表、模型原始输出都要存;否则你只能看到“选错了”,不知道为什么错。
我还会让模型在工具调用前输出一句简短的 reason,比如“日志里出现 downstream timeout,下一步查依赖服务”。这句话对排查误选非常有用。
一个真实例子:模型用 query_metrics 去查 timeout,参数完全合法,但语义错了。日志里表现为“空结果 + 连续两次调用 + 最终答案没有证据”。后来把指标工具的描述改成“只用于数值趋势,不用于日志文本检索”,误选就降下来了。
加分句:可观测不是“打日志”,是“能回放决策过程”。
Q9. 有没有试过不给 tools 列表,只靠 messages 让它自己调工具?
一句话答案:试过,早期原型就是纯文本 ReAct。能跑,但不稳,不适合生产;生产环境还是用原生 function calling / tools,把工具定义显式给模型。
展开回答:
不给 tools 列表时,模型只能从 messages 里“猜”有哪些工具、参数怎么填。它确实能写出:
Thought: 需要查日志
Action: query_logs
Action Input: {"service": "order-service", ...}但问题很多:
- 工具名幻觉:模型会编出不存在的工具,或者把
query_logs写成search_logs。 - 参数不可控:没有 JSON Schema 约束,类型、必填、枚举都靠模型自觉。
- 解析脆弱:你要自己解析 Thought/Action/Action Input,格式一变就崩。
- 没有结构化调用协议:没有
tool_call_id,没有并行调用,没有标准错误回传。 - 工具定义还是占上下文:你把工具说明写在 messages 里,Token 一样省不掉,只是从结构化变成了自然语言。
所以我的结论是:文本 ReAct 是兼容手段,不是生产首选。模型不支持原生 tool API 时才用;支持的话,一定用原生 tools/function calling,把 name、description、JSON Schema 显式给它,让模型在约束下选择。
陷阱应对:面试官问“为什么不只靠 messages?”——答“因为工具定义是契约,不是聊天内容;不给契约,模型只能猜,生产环境不可控”。
Q10. 那它怎么知道有哪些工具可用?
一句话答案:靠工具注册表 + 按需注入。运行时把当前 Agent/用户有权限的工具,以 name + description + JSON Schema 的形式放进 tools 字段;30 个工具时先路由/检索出候选集,再注入。
展开回答:
模型不会“天然知道”你有哪些工具,它知道是因为你把工具定义放进了上下文。具体机制:
- 注册:每个工具注册到 Registry,带 name、description、schema、权限、版本、危险等级。
- 过滤:按租户、用户角色、Agent 能力过滤,只给有权限的工具;高危工具额外标记需要确认。
- 路由/检索:30 个工具不一次性全给。先做意图/领域分类,再用工具描述 Embedding 召回 Top 5-8,组成候选集。
- 注入:把候选工具的
name / description / parameters放进tools字段;模型从这里选。 - 发现:如果工具来自 MCP Server,就先调
tools/list拿到工具清单,再转成 function calling 的 Schema。 - 版本化:工具 Schema 带版本,变更要兼容;否则模型按旧描述调用,参数对不上。
关键点:工具列表是“当前这一步可用的工具”,不是“系统里所有工具”。动态裁剪候选集,既能提高准确率,也能省 Token。
加分句:模型对工具的认知,完全来自你注入的上下文;你不给,它就猜;你给太多,它就乱选。
三、MCP(Q11-Q12)
Q11. MCP 用过吗?它跟 function calling 是替代关系还是两层?
一句话答案:用过。不是替代,是两层:function calling 是模型“怎么发起工具调用”的能力,MCP 是工具“怎么标准化接入、发现和复用”的协议。
展开回答:
| Function Calling | MCP | |
|---|---|---|
| 解决什么 | 模型输出结构化的工具调用 | 工具/资源的标准化接入与发现 |
| 范围 | 单次模型调用 | 跨进程、跨服务、跨厂商 |
| 协议 | 模型厂商的 API | JSON-RPC 2.0 |
| 发现方式 | tools 参数直接给 | tools/list 动态发现 |
| 传输 | 模型 API 内部 | stdio / HTTP+SSE / Streamable HTTP |
| 谁实现 | 模型厂商 | MCP Server / Client |
一次完整的调用链是:
MCP Client 调 tools/list 拿到工具清单
→ 转成 function calling 的 schema
→ 模型输出 tool_call
→ MCP Client 调 tools/call 执行
→ 结果回填给模型所以 MCP 不是“替代 function calling”,它只是把工具的定义、发现、调用标准化了;模型最终还是要用 function calling 来决定调哪个工具。
MCP 的价值在于复用和解耦:同一个 MCP Server 可以给多个 Agent、多个客户端用;工具实现和 Agent 代码解耦;跨厂商也能接。代价是多一跳、鉴权、延迟、版本管理和安全边界,不是所有内部工具都值得包成 MCP。
我们的做法:第三方通用能力(GitHub、文件系统、数据库)用 MCP;内部高频工具直接走函数调用/RPC,避免多一层开销;需要跨团队复用的工具才包 MCP Server。
加分句:Function calling 是“手怎么动”,MCP 是“工具箱怎么标准化接入”。两者是互补,不是替代。
Q12. 远程的 MCP 传输用的什么?SSE 有什么毛病?
一句话答案:本地用 stdio;远程早期用 HTTP+SSE,现在推荐 Streamable HTTP。SSE 的毛病主要是单向、长连接脆弱、鉴权受限、代理不友好、水平扩展难。
展开回答:
MCP 的传输演进:
- stdio:本地进程,Client 和 Server 通过标准输入输出通信,简单、无网络开销,适合本地工具。
- HTTP+SSE:早期远程方案。Server 用一个 SSE 长连接把消息推给 Client,Client 另开一个 POST 端点发请求。
- Streamable HTTP:较新的远程方案。单一端点,Client 用 POST 发请求,Server 可以用普通 JSON 响应,也可以升级为 SSE 流;支持 session id、断点恢复和无状态模式。
SSE 的具体毛病:
- 单向。SSE 只能 Server→Client 推,Client→Server 要另开 POST,两个端点、两套连接管理。
- 长连接脆弱。网关、负载均衡、反向代理经常有 idle timeout,连接会被断开;重连要恢复 session、处理
Last-Event-ID;多实例部署还要做粘性会话。 - 鉴权受限。浏览器原生
EventSource不能带自定义 Header,Token 只能放 Query 或 Cookie,容易泄露、也容易有 CSRF 问题;实际要用 fetch-based SSE 或 polyfill。 - 代理不友好。代理可能缓冲 SSE、压缩事件流,导致消息延迟;要专门关闭 buffering。
- 扩展难。每个 Client 一条长连接,连接数膨胀;水平扩容时 session 状态难共享。
- 错误语义弱。流里的错误只能作为 event 传,HTTP 状态码在流开始后就不直观;重试语义不统一。
Streamable HTTP 解决的就是这些问题:单端点、POST + 可选 SSE、session id、可恢复、支持无状态部署,更适合网关和 Serverless。
我们的选择:第三方远程 MCP 优先用 Streamable HTTP;老服务兼容才用 HTTP+SSE;内部高频工具不走 MCP,直接 RPC。鉴权用 OAuth 2.1 / Bearer,不把 Token 放 Query。
加分句:SSE 不是“不能用”,是“不适合做远程工具接入的主传输”;能升级到 Streamable HTTP 就升级。
四、RAG 与上下文(Q13-Q17)
Q13. RAG 这块,切块大小你怎么定的?
一句话答案:不固定 512。按文档结构、查询类型、Embedding 模型和上下文预算来定;先做结构切分,再用“小 chunk 检索 + 大 parent 生成”,最后用评测扫参数。
展开回答:
我的步骤:
- 结构优先。按标题、段落、表格、代码块切,不要按固定字符数硬切。表格切碎、代码切半,后面怎么检索都救不回来。
- 初始大小。中文文档一般 300-600 字,约 200-500 Token;重叠 10%-20%。这是一个起点,不是答案。
- 按文档类型调整。FAQ 用小块(200-400 字);政策、合同、手册用大块(500-1000 字);代码按函数/类切,保留 import 和上下文。
- Parent-Child。子块 200-300 字用于检索,命中后取父块(整节)给模型生成。这样既保证召回精度,又保证生成时有完整上下文。
- 元数据。每个 chunk 带 title、section path、source、version、effective date。检索时按版本和时效过滤。
- 不要切碎的东西。表格、公式、代码、流程图说明尽量保持完整;必要时单独建索引。
- 评测扫参数。用标注集扫 256/512/1024 Token 和不同 overlap,看 Recall@k 和最终答案准确率,选数据最好的,不选“感觉最顺的”。
如果是多跳问题,单纯加大 chunk 不一定有用,可以考虑 Parent-Child、句子窗口,或者 GraphRAG 把实体关系抽出来。
加分句:切块大小不是拍一个数字,是“文档结构 + 查询类型 + 评测结果”三者共同决定的。
Q14. 定完之后测过吗?召回多少?
一句话答案:测过。用 300 条标注 Query,Recall@5 大概 86%、Recall@10 大概 93%;加混合检索和 Rerank 后 Recall@5 到 92% 左右。但我会同时报分片结果,不只报一个总数。
展开回答:
指标口径要先说清楚:
- Recall@k:金标准证据 chunk 是否出现在 Top-k;
- MRR / nDCG:正确 chunk 排得靠不靠前;
- 端到端答案准确率:召回对了,生成是否答对;
- 分片:单跳、多跳、表格/数字、长尾/新文档,分别看。
示例数据(换成你的真实数据):
| 分片 | Recall@5 | 说明 |
|---|---|---|
| 单跳事实 | 94% | 直接命中 |
| 多跳推理 | 76% | 需要跨文档,明显弱 |
| 表格/数字 | 68% | 表格被切碎,最需要优化 |
| 总体 | 86% | 被长尾拉低 |
优化后:混合检索(BM25 + 向量)+ Rerank,总体 Recall@5 到 92%,表格分片通过“表格不切 + 单独索引”提到 85%。
注意:不要只回答“召回 90%”。要说明是 Recall@几、多少条数据、谁标的、口径是什么。否则面试官会继续追问,而且很容易露馅。
Q15. 召回不准的时候,你怎么判断是切分的问题还是 embedding 的问题?
一句话答案:用消融定位,不要猜。先看金标准证据在不在 chunk 里(切分问题),再看 chunk 在不在 Top-k(Embedding/排序问题),最后看 Top-k 有证据但答错(生成问题)。
展开回答:
我按这个顺序排查:
- 证据在不在库里? 如果原文有答案,但任何 chunk 都不包含它,说明切分把答案切掉了,或者索引没建全。
- chunk 是否完整? 如果答案被切成两半,或者 chunk 缺标题/上下文,就是切分问题;用 Parent-Child 或句子窗口解决。
- chunk 在不在 Top-k? 如果 chunk 完整存在但没召回,比较 BM25 和向量检索:
- BM25 命中、向量没命中 → Embedding 对领域词/编号不敏感,加混合检索、领域微调或关键词字段;
- 两者都没命中 → Query 表达问题或索引问题,做 Query 改写、HyDE、多路召回。
- Top-k 有证据但答错? 这不是召回问题,是生成或上下文组装问题;看证据是否被截断、是否排在很后面、Prompt 是否要求引用。
- 做单变量消融:固定切分换 Embedding,或固定 Embedding 换切分,看 Recall 变化。一次只改一个变量,才能归因。
举两个真实例子:
- 错误码
E1024向量检索召回很差,BM25 能命中 → 这不是切分问题,是 Embedding 对稀有 Token 不敏感;加 BM25 混合检索解决。 - 答案跨两个小节,每个 chunk 只有一半 → 这是切分问题;用 Parent-Child 取整节解决。
加分句:召回不准时,最忌讳“直接换个 Embedding 模型”;先定位是切分、索引、Query 还是生成,再动手。
Q16. 上下文超了怎么办?压过吗?
一句话答案:压过。顺序是“预算管理 → 结构化状态替代历史 → 工具结果裁剪/摘要 → 最后才硬截断”;压缩时优先删寒暄和重复工具输出,绝不删系统规则、用户约束、ID、数字和未解决问题。
展开回答:
我的上下文预算大致是:
System/规则 10% | 任务状态 15% | 检索证据 35% |
对话历史 25% | 输出预留 15%到 75% 左右开始压缩,不等溢出。
具体手段:
- 结构化任务状态。用一个 JSON 维护目标、约束、已完成、待办、失败记录,替代把几十轮对话全塞进去。状态比历史便宜,也更准。
- 滚动摘要。老的对话轮次做摘要,保留最近 N 轮和关键轮次(用户纠正、决策、关键实体)。
- 工具结果裁剪。原始结果存外部,只把摘要和引用放上下文;日志只保留错误窗口、关键行和 trace_id,不要整段贴。
- 按需加载。常驻摘要 + 索引,证据用的时候再取;大文件调研丢给子 Agent,只回传摘要。
- Prompt Cache。System、工具定义、固定规则放最前且顺序稳定,变化内容放后面。
- 硬截断兜底。最后才按 Token 截断,而且从低价值内容开始删。
必须保留的东西:系统规则、用户明确约束、关键 ID/数字、时间范围、已做出的决策、未解决的问题。压缩最容易丢的就是这些,丢了就会“答得挺顺,但约束全忘”。
加分句:上下文管理的目标不是“记住更多”,而是“在有限窗口里,让模型看到当前决策最需要的信息”。
Q17. 压完效果掉多少,你用什么衡量的?
一句话答案:用同一套评测集做配对 A/B,看任务成功率、答案正确性、忠实度、关键事实召回、约束遵守、时延和 Token;示例是 Token 降 45%、成功率降 1.8 个百分点,多跳任务降 4 个百分点;如果降超过 5 个百分点,就回退或改压缩策略。
展开回答:
我不会只看“压缩后回答还通不通顺”,而是看这些指标:
| 指标 | 衡量什么 |
|---|---|
| 任务成功率 | 端到端是否完成目标 |
| 答案正确性 | 与金标准答案是否一致 |
| 忠实度 | 结论是否有证据支撑,是否编造 |
| 关键事实召回 | 压缩后关键数字/ID/条件是否还在 |
| 约束遵守 | 早期用户约束是否还被遵守 |
| 格式合规 | Schema/格式是否仍然正确 |
| 时延/Token | 压缩带来的收益 |
做法:同一批 300 条评测 Query,一条走原上下文,一条走压缩上下文,配对比较;分片看单跳、多跳、长上下文、数字类问题。
示例结论(换成你的真实数据):Token 降 45%,总体成功率降 1.8 个百分点,单跳基本不变,多跳降 4 个百分点。这个代价可以接受;如果降超过 5 个百分点,我会调整压缩策略,比如保留更多关键轮次、改用结构化状态、换更好的摘要器。
线上再看:用户纠正率、人工接管率、重试率、点踩率。压缩导致的“隐性退化”往往先体现在这些指标上。
加分句:压缩不是免费午餐;要量化它换来了多少 Token、损失了多少效果,再决定值不值。
五、评测集(Q18)
Q18. 你们评测集谁标的,标了多少条,口径是什么?
一句话答案:业务专家 2-3 人 + 我定义口径和抽样;300-500 条起步,双标 + 第三人仲裁;口径不是“答得像不像”,而是“问题 + 金标准答案 + 金标准证据 + 期望工具 + 评分规则”五件套。
展开回答:
谁标。 业务专家/一线值班同学 2-3 人,他们知道真实根因和正确处置;我负责定义评分口径、抽样、版本管理和回归。标注不是拉个实习生随便点,口径必须先对齐。
标多少。 冷启动 100-200 条先跑起来;稳定后 300-500 条;保留 20% 作为 holdout,不参与调 Prompt。覆盖要分层:常见场景、长尾场景、边界条件、对抗输入、工具失败、无答案(应该拒答)。
口径五件套。 每条数据包含:
- Query:真实用户问题,脱敏后使用;
- Gold Answer:金标准答案,允许多种等价表达;
- Gold Evidence:回答问题所需的最小证据 chunk ID,用于算 Recall@k;
- Expected Tools:期望的工具序列或工具集合,用于算工具选择准确率;
- Rubric:评分维度和规则,比如正确性 0/1/2、忠实度、完整性、格式、安全。
评分规则要写死。 比如:
- 正确性:0 完全错,1 部分对,2 完全对;
- 忠实度:结论必须有证据支撑,编造直接 0;
- 完整性:是否覆盖问题的所有子项;
- 格式:是否符合 Schema;
- 拒答:该拒答时拒答算对,不该拒答时拒答算错。
流程。 先标 50 条做校准,开会对齐口径;然后双标,算一致率/Cohen's kappa;分歧由第三人仲裁;定期复审,防止标注漂移。
维护。 评测集版本化;每次改 Prompt、换模型、加工具都跑回归;线上 bad case 定期回流,但不把测试集样本直接拿去调 Prompt,避免过拟合。
工具选择怎么算。 期望工具是集合就用集合匹配;有依赖顺序就用序列匹配;不要求顺序的,不因为顺序不同判错。
加分句:评测集的质量,决定了你后面所有优化的方向;口径不清,指标越高越危险。
六、手撕 ReAct(Q19-Q20)
Q19. 手撕:手写一个 ReAct 循环,要有最大步数,工具抛异常要能兜住。
一句话答案:核心是“状态机 + 最大步数 + 工具异常转 observation + 未知工具提示 + 每次调用可追踪”;不是让模型自由发挥,而是让它在约束里循环。
完整代码(可直接运行):
from __future__ import annotations
import json
import time
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Protocol
@dataclass
class Tool:
name: str
description: str
input_schema: dict
func: Callable[..., Any]
repeatable: bool = False # 轮询类工具允许相同参数重复调用
@dataclass
class ToolCall:
id: str
name: str
arguments: dict
@dataclass
class LLMResponse:
content: str | None = None
tool_call: ToolCall | None = None
class LLM(Protocol):
def chat(self, messages: list[dict], tools: list[dict]) -> LLMResponse:
...
class ToolRegistry:
def __init__(self, tools: list[Tool]):
self._tools = {tool.name: tool for tool in tools}
def specs(self) -> list[dict]:
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
},
}
for tool in self._tools.values()
]
def has(self, name: str) -> bool:
return name in self._tools
def is_repeatable(self, name: str) -> bool:
tool = self._tools.get(name)
return bool(tool and tool.repeatable)
def execute(self, name: str, arguments: dict) -> dict:
tool = self._tools.get(name)
if tool is None:
return {"ok": False, "error": f"unknown_tool: {name}"}
required = tool.input_schema.get("required", [])
missing = [key for key in required if key not in arguments]
if missing:
return {"ok": False, "error": f"invalid_arguments: missing {missing}"}
try:
result = tool.func(**arguments)
return {"ok": True, "result": result}
except Exception as exc:
# 工具异常不能打断循环,转成 observation 让模型自己换策略
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
def run_react(
llm: LLM,
registry: ToolRegistry,
task: str,
max_steps: int = 8,
max_same_call: int = 3,
) -> dict:
trace_id = str(uuid.uuid4())
messages: list[dict] = [
{"role": "system", "content": "你是运维排障 Agent。先查证据,再下结论;不要编造。"},
{"role": "user", "content": task},
]
call_counts: dict[tuple[str, str], int] = {}
trace: list[dict] = []
for step in range(1, max_steps + 1):
started = time.time()
try:
response = llm.chat(messages, registry.specs())
except Exception as exc:
return {
"status": "llm_error",
"answer": None,
"steps": step,
"error": str(exc),
"trace": trace,
}
# 没有 tool_call,说明模型给出最终答案,循环结束
if response.tool_call is None:
return {"status": "ok", "answer": response.content, "steps": step, "trace": trace}
call = response.tool_call
# 参数规范化,保证 {"a":1,"b":2} 和 {"b":2,"a":1} 被视为同一个调用
canonical_args = json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
call_key = (call.name, canonical_args)
call_counts[call_key] = call_counts.get(call_key, 0) + 1
messages.append({
"role": "assistant",
"content": response.content,
"tool_calls": [{
"id": call.id,
"type": "function",
"function": {"name": call.name, "arguments": canonical_args},
}],
})
if not registry.has(call.name):
observation = {
"ok": False,
"error": f"unknown_tool: {call.name}",
"hint": "请只使用工具列表中存在的工具。",
}
elif call_counts[call_key] >= max_same_call and not registry.is_repeatable(call.name):
observation = {
"ok": False,
"error": "repeated_call",
"hint": "相同工具和参数已连续调用多次。请基于已有结果作答,或更换工具/参数。",
}
else:
observation = registry.execute(call.name, call.arguments)
trace.append({
"trace_id": trace_id,
"step": step,
"tool": call.name,
"arguments": call.arguments,
"ok": observation.get("ok"),
"observation": observation,
"latency_ms": round((time.time() - started) * 1000, 2),
})
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(observation, ensure_ascii=False),
})
# 达到最大步数:不返回一个编造的答案,返回失败状态和 trace,交给上层降级
return {"status": "max_steps", "answer": None, "steps": max_steps, "trace": trace}
if __name__ == "__main__":
def get_alert(alert_id: str) -> dict:
if alert_id != "A1001":
raise ValueError("alert not found")
return {"alert_id": "A1001", "service": "order-service", "symptom": "5xx 升高"}
registry = ToolRegistry([
Tool(
name="get_alert",
description="根据告警 ID 查询告警详情,用于排障第一步。",
input_schema={
"type": "object",
"properties": {"alert_id": {"type": "string"}},
"required": ["alert_id"],
},
func=get_alert,
),
])
class DemoLLM:
"""演示用假模型:第 1、2 次正常调工具,第 3 次触发重复调用拦截,最后收敛。"""
def __init__(self):
self.step = 0
def chat(self, messages, tools):
self.step += 1
if self.step == 1:
return LLMResponse(tool_call=ToolCall("c1", "get_alert", {"alert_id": "A1001"}))
if self.step == 2:
return LLMResponse(tool_call=ToolCall("c2", "get_alert", {"alert_id": "A1001"}))
if self.step == 3:
return LLMResponse(tool_call=ToolCall("c3", "get_alert", {"alert_id": "A1001"}))
return LLMResponse(content="A1001 是 order-service 的 5xx 告警,建议先查下游超时。")
result = run_react(DemoLLM(), registry, "帮我看一下 A1001 这条告警")
print(json.dumps(result, ensure_ascii=False, indent=2))代码要点说明:
- 最大步数:
max_steps是硬上限,到了就返回max_steps状态,不编答案,交给上层降级或人工。 - 工具异常兜底:
ToolRegistry.execute捕获Exception,把异常转成{"ok": False, "error": ...}的 observation,循环继续,模型可以根据错误换工具/换参数。 - 未知工具:不执行,直接回一条带 hint 的 observation,避免模型继续幻觉。
- 参数校验:至少校验 required 字段;生产环境用
jsonschema或 Pydantic 做完整校验。 - 可追踪:每一步记录工具、参数、结果、耗时,带
trace_id,可以回放。 - 生产还要加:工具超时、Token 预算、工具级重试、危险工具人工确认、幂等键、日志脱敏。
面试口述版:先讲循环骨架,再讲三个兜底(异常、未知工具、最大步数),最后讲 trace 和重复调用;不要一上来就写几十行 SDK 调用。
Q20. 如果模型连着三次调同一个工具、同一个参数,你的循环怎么处理?
一句话答案:用 (tool_name, 规范化参数) 做计数;第二次提示并复用已有结果,第三次不再执行、强制换策略或收敛;轮询类工具用白名单 + TTL 区分“合法重复”。
展开回答:
第一步,识别“同一个调用”。 Key 用 (tool_name, canonical_args);参数要规范化:JSON key 排序、数字/字符串归一、去掉无意义的 request_id、timestamp 这类每次都会变的字段。否则模型每次换个时间戳,你就识别不出来。
第二步,分层处理。
| 次数 | 行为 |
|---|---|
| 第 1 次 | 正常执行 |
| 第 2 次 | 执行,但在 observation 里附上“你刚刚已用相同参数调用过,结果如下,请基于已有结果继续” |
| 第 3 次 | 不再执行,返回 repeated_call,并附上已有结果;要求模型换工具、换参数或直接给结论 |
| 第 4 次及以后 | 直接终止循环,返回部分结果 + trace,转人工或降级 |
第三步,区分“合法重复”。 不是所有重复都是死循环:
- 轮询部署状态、任务进度这类工具,重复调用是正常的;给工具加
repeatable=True,允许重复,但要有 TTL 和状态变化检测。 - 查询类工具重复调用同一参数,结果不会变,应该拦截。
- 如果工具结果里有“状态仍在进行中”,模型重复查是合理的;如果结果已经是终态,还重复查,就是卡住了。
第四步,找根因。 模型卡住通常不是模型笨,而是:
- 工具描述模糊,模型不确定该用哪个;
- 工具报错信息没用,模型不知道该怎么改;
- observation 太长被截断,模型以为没拿到结果;
- 工具返回空,模型想再试一次;
- Prompt 没告诉它“拿到足够证据就收敛”。
对应修法:改描述、改错误信息、裁剪 observation、加“已有结果请直接使用”的提示、在 System Prompt 里写清停止条件。
第五步,观测。 记录 repeat_rate、stuck_rate、平均步数、人工接管率;重复率突然升高,通常是新工具描述或新模型引入的回归。
代码位置:上面的 run_react 已经实现了这套逻辑——call_counts 计数、max_same_call 阈值、repeatable 白名单、repeated_call observation。面试时可以直接指着这段讲。
加分句:不要靠“把 max_steps 调大”来解决重复调用;那只是让死循环跑得更久。要在循环里显式识别重复、给出反馈、强制收敛。
面试收尾:三句话总结
- Agent 选型:默认写死,只有“路径不确定、结果可复核”的长尾场景才用 Agent;高危动作永远保留人工确认。
- 工具工程:工具描述是 Prompt 的一部分;工具多了要分组、路由、按需注入,不是一次性全塞。
- RAG 与上下文:切块靠评测定,不靠拍脑袋;召回不准先消融定位;上下文压缩要量化效果损失。
高频追问速查
| 追问 | 一句话回答 |
|---|---|
| 为什么不用一个 Agent 全包? | 上下文隔离、能力专精、并行、可观测;代价是协调复杂度 |
| 工具选错怎么发现? | 结构化 trace + 离线混淆矩阵 + 重复/空结果/无证据指标 |
| MCP 能替代 function calling 吗? | 不能,MCP 是接入协议,function calling 是模型调用能力 |
| SSE 最大的问题? | 单向、长连接脆弱、鉴权受限、代理不友好、扩展难 |
| 切块多大合适? | 结构切分 + 300-600 字起步 + Parent-Child + 评测扫参数 |
| 召回不准先查什么? | 先看金标准证据在不在 chunk 里,再看在不在 Top-k |
| 上下文超了先删什么? | 寒暄、重复工具输出、低价值历史;保留规则、约束、ID、数字 |
| 压缩效果怎么衡量? | 同一评测集配对 A/B:成功率、忠实度、关键事实、约束遵守 |
| 评测集多少条? | 100-200 冷启动,300-500 稳定,20% holdout |
| 模型重复调同一工具怎么办? | 计数 + 第二次提示 + 第三次拦截 + 轮询白名单 + 最大步数兜底 |