Skip to content

智谱 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 秒,成本低,结果确定,测试也好写。但遇到这几类就露馅:

  1. 关键词没命中就结束。固定流程查 ERROR 没查到,就认为没问题;Agent 会换关键词、扩大时间窗、查下游调用链。
  2. 根因在下游,流程没有这个分支。日志里写的是 downstream timeout,固定流程不会去查依赖服务;Agent 会顺着这条线索继续查。
  3. 工具返回空或异常,流程直接报错。Agent 会把“空结果”当成一条 observation,换个工具再试。
  4. 新告警类型要改代码发版。每加一种故障模式就要加分支,维护成本随分支数量爆炸。

所以最后的架构是混合:规则路由先判断是不是已知告警,已知的走固定 runbook;未知的、或者 runbook 执行失败的,交给 Agent 兜底。Agent 也可以把 runbook 当成一个工具来调用。

为什么这么选:固定流程在确定性场景下更快、更便宜、更可控,没理由为了“用 Agent”而放弃它。Agent 是长尾兜底,不是万能替代。


Q4. 那你怎么判断一个任务该给 Agent 还是该写死? ​

一句话答案:用四个维度判断——路径确定性、输入开放性、风险等级、变更频率;默认写死,只有“不确定性带来的收益”大于“协调成本”时才上 Agent。

展开回答:

维度更适合写死更适合 Agent
路径确定性分支可枚举、SOP 固定分支不可枚举、依赖中间结果
输入开放性结构化参数、字段固定自然语言、模糊描述、多模态
风险等级高危、不可逆、强合规可复核、可回滚、有人工确认
变更频率流程稳定、工具少工具/流程频繁增加
成本时延高频、低延迟、成本敏感低频、复杂、可接受多步调用

落到具体判断,我会问自己四个问题:

  1. 这件事能不能用三个 if-else 加一个模板覆盖?能,就写死。
  2. 下一步是否依赖上一步的返回内容?不依赖,就写死。
  3. 错了能不能复核/回滚?不能,就写死加人工,不要给 Agent 自主权。
  4. 工具会不会持续增加?不会,就写死;会,就用 Agent + 工具注册表。

最常见的落地形态是混合架构:外层是固定流程,负责权限、风控、审计、兜底;内层把“不确定的节点”交给 Agent。这样既保留确定性,又能处理长尾。

加分句:Agent 是处理不确定性的手段,不是目的。能用流程解决的,就不要用 Agent。


二、工具工程(Q5-Q10) ​

Q5. 工具是你自己写的还是接的现成的? ​

一句话答案:混合。能接现成的就接现成的;现成的不满足契约、权限或审计要求的,自己包一层适配器。不重复造轮子,也不把裸 API 直接塞给模型。

展开回答:

我的工具来源大概分三类:

  1. 现成 API 包装:告警平台、日志平台、Prometheus、发布系统、CMDB、工单系统、知识库检索。这些系统本身有 API,我写一层薄适配器。
  2. 现成 MCP Server:文件系统、GitHub、数据库查询这类通用能力,直接接现成的 MCP Server,不自己重写。
  3. 自己写的业务工具:把内部系统里“面向人”的接口,改造成“面向模型”的工具,比如把分页、字段裁剪、错误归一化、脱敏都封装掉。

自己包一层的原因,不是因为喜欢写代码,而是因为裸 API 直接给模型会有这些问题:

  • 鉴权分散,每个工具都要重复处理 Token;
  • 返回字段太多,塞进上下文又贵又噪;
  • 错误信息是给开发看的,模型看不懂,无法根据错误换策略;
  • 没有超时、重试、分页、幂等和审计;
  • 高危操作没有二次确认。

所以我们会有一个统一的 Tool 网关/注册表:每个工具有 name、description、JSON Schema、owner、版本、SLA、危险等级、权限范围。Agent 只认注册表,不直接认某个 SDK。

加分句:工具不是“ API 的裸包装”,而是“给模型用的产品”。面向人的接口和面向模型的工具,设计目标不一样。


Q6. 工具描述你怎么写的?一个工具写多少字? ​

一句话答案:工具描述是“给模型看的 API 文档 + 选择指南”,不是越短越好。常规工具 80-200 字,复杂工具 300 字左右,控制在 400 Token 以内;核心是写清“什么时候用、什么时候不用、参数怎么填、失败长什么样”。

展开回答:

我用的模板是:

yaml
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")

几个关键点:

  1. 名字用动词 + 对象,比如 query_logs、get_deploy_history,不要用 log_tool 这种模糊名字。
  2. 必须写“什么时候不用”。只写“什么时候用”,模型会把相似工具乱选;写清边界,误选会明显下降。
  3. 参数给类型、必填、枚举、示例。能枚举就不要让模型自由生成,比如环境用 dev/staging/prod,不要让它猜。
  4. 错误要可读。不要返回 HTTP 500 就完事,要告诉模型“服务名不存在,请检查 service 参数”。
  5. 长度控制。太短无法区分边界,太长会稀释注意力、增加 Token。30 个工具时,描述总量本身就会占掉几千 Token。

我不会按“一个工具必须写多少字”来定,而是按“能不能和相邻工具区分开”来定。写完用工具选择评测集测混淆矩阵,哪个工具经常被误选,就改哪个的描述。

加分句:工具描述不是文档,是 Prompt 的一部分;它直接决定模型选不选你。


Q7. 工具从 5 个涨到 30 个之后,模型选错的概率有没有变高? ​

一句话答案:会变高,尤其是工具名相似、描述重叠、参数边界模糊的时候;但不是线性上升,靠分组、路由和工具检索可以把准确率拉回来。

展开回答:

变高的原因有四个:

  1. 候选过载:5 个工具时模型很容易比较;30 个工具时注意力被稀释,容易选“看起来差不多”的。
  2. Schema Token 膨胀:30 个工具、每个 150-300 Token,光工具定义就 4500-9000 Token,既贵又挤占上下文。
  3. 能力重叠:query_logs 和 query_archive_logs、get_deploy_history 和 get_change_records,名字和描述都像,模型必然混。
  4. 参数歧义:同一个字段在不同工具里含义不同,比如 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", ...}

但问题很多:

  1. 工具名幻觉:模型会编出不存在的工具,或者把 query_logs 写成 search_logs。
  2. 参数不可控:没有 JSON Schema 约束,类型、必填、枚举都靠模型自觉。
  3. 解析脆弱:你要自己解析 Thought/Action/Action Input,格式一变就崩。
  4. 没有结构化调用协议:没有 tool_call_id,没有并行调用,没有标准错误回传。
  5. 工具定义还是占上下文:你把工具说明写在 messages 里,Token 一样省不掉,只是从结构化变成了自然语言。

所以我的结论是:文本 ReAct 是兼容手段,不是生产首选。模型不支持原生 tool API 时才用;支持的话,一定用原生 tools/function calling,把 name、description、JSON Schema 显式给它,让模型在约束下选择。

陷阱应对:面试官问“为什么不只靠 messages?”——答“因为工具定义是契约,不是聊天内容;不给契约,模型只能猜,生产环境不可控”。


Q10. 那它怎么知道有哪些工具可用? ​

一句话答案:靠工具注册表 + 按需注入。运行时把当前 Agent/用户有权限的工具,以 name + description + JSON Schema 的形式放进 tools 字段;30 个工具时先路由/检索出候选集,再注入。

展开回答:

模型不会“天然知道”你有哪些工具,它知道是因为你把工具定义放进了上下文。具体机制:

  1. 注册:每个工具注册到 Registry,带 name、description、schema、权限、版本、危险等级。
  2. 过滤:按租户、用户角色、Agent 能力过滤,只给有权限的工具;高危工具额外标记需要确认。
  3. 路由/检索:30 个工具不一次性全给。先做意图/领域分类,再用工具描述 Embedding 召回 Top 5-8,组成候选集。
  4. 注入:把候选工具的 name / description / parameters 放进 tools 字段;模型从这里选。
  5. 发现:如果工具来自 MCP Server,就先调 tools/list 拿到工具清单,再转成 function calling 的 Schema。
  6. 版本化:工具 Schema 带版本,变更要兼容;否则模型按旧描述调用,参数对不上。

关键点:工具列表是“当前这一步可用的工具”,不是“系统里所有工具”。动态裁剪候选集,既能提高准确率,也能省 Token。

加分句:模型对工具的认知,完全来自你注入的上下文;你不给,它就猜;你给太多,它就乱选。


三、MCP(Q11-Q12) ​

Q11. MCP 用过吗?它跟 function calling 是替代关系还是两层? ​

一句话答案:用过。不是替代,是两层:function calling 是模型“怎么发起工具调用”的能力,MCP 是工具“怎么标准化接入、发现和复用”的协议。

展开回答:

Function CallingMCP
解决什么模型输出结构化的工具调用工具/资源的标准化接入与发现
范围单次模型调用跨进程、跨服务、跨厂商
协议模型厂商的 APIJSON-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 的传输演进:

  1. stdio:本地进程,Client 和 Server 通过标准输入输出通信,简单、无网络开销,适合本地工具。
  2. HTTP+SSE:早期远程方案。Server 用一个 SSE 长连接把消息推给 Client,Client 另开一个 POST 端点发请求。
  3. Streamable HTTP:较新的远程方案。单一端点,Client 用 POST 发请求,Server 可以用普通 JSON 响应,也可以升级为 SSE 流;支持 session id、断点恢复和无状态模式。

SSE 的具体毛病:

  1. 单向。SSE 只能 Server→Client 推,Client→Server 要另开 POST,两个端点、两套连接管理。
  2. 长连接脆弱。网关、负载均衡、反向代理经常有 idle timeout,连接会被断开;重连要恢复 session、处理 Last-Event-ID;多实例部署还要做粘性会话。
  3. 鉴权受限。浏览器原生 EventSource 不能带自定义 Header,Token 只能放 Query 或 Cookie,容易泄露、也容易有 CSRF 问题;实际要用 fetch-based SSE 或 polyfill。
  4. 代理不友好。代理可能缓冲 SSE、压缩事件流,导致消息延迟;要专门关闭 buffering。
  5. 扩展难。每个 Client 一条长连接,连接数膨胀;水平扩容时 session 状态难共享。
  6. 错误语义弱。流里的错误只能作为 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 生成”,最后用评测扫参数。

展开回答:

我的步骤:

  1. 结构优先。按标题、段落、表格、代码块切,不要按固定字符数硬切。表格切碎、代码切半,后面怎么检索都救不回来。
  2. 初始大小。中文文档一般 300-600 字,约 200-500 Token;重叠 10%-20%。这是一个起点,不是答案。
  3. 按文档类型调整。FAQ 用小块(200-400 字);政策、合同、手册用大块(500-1000 字);代码按函数/类切,保留 import 和上下文。
  4. Parent-Child。子块 200-300 字用于检索,命中后取父块(整节)给模型生成。这样既保证召回精度,又保证生成时有完整上下文。
  5. 元数据。每个 chunk 带 title、section path、source、version、effective date。检索时按版本和时效过滤。
  6. 不要切碎的东西。表格、公式、代码、流程图说明尽量保持完整;必要时单独建索引。
  7. 评测扫参数。用标注集扫 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 有证据但答错(生成问题)。

展开回答:

我按这个顺序排查:

  1. 证据在不在库里? 如果原文有答案,但任何 chunk 都不包含它,说明切分把答案切掉了,或者索引没建全。
  2. chunk 是否完整? 如果答案被切成两半,或者 chunk 缺标题/上下文,就是切分问题;用 Parent-Child 或句子窗口解决。
  3. chunk 在不在 Top-k? 如果 chunk 完整存在但没召回,比较 BM25 和向量检索:
    • BM25 命中、向量没命中 → Embedding 对领域词/编号不敏感,加混合检索、领域微调或关键词字段;
    • 两者都没命中 → Query 表达问题或索引问题,做 Query 改写、HyDE、多路召回。
  4. Top-k 有证据但答错? 这不是召回问题,是生成或上下文组装问题;看证据是否被截断、是否排在很后面、Prompt 是否要求引用。
  5. 做单变量消融:固定切分换 Embedding,或固定 Embedding 换切分,看 Recall 变化。一次只改一个变量,才能归因。

举两个真实例子:

  • 错误码 E1024 向量检索召回很差,BM25 能命中 → 这不是切分问题,是 Embedding 对稀有 Token 不敏感;加 BM25 混合检索解决。
  • 答案跨两个小节,每个 chunk 只有一半 → 这是切分问题;用 Parent-Child 取整节解决。

加分句:召回不准时,最忌讳“直接换个 Embedding 模型”;先定位是切分、索引、Query 还是生成,再动手。


Q16. 上下文超了怎么办?压过吗? ​

一句话答案:压过。顺序是“预算管理 → 结构化状态替代历史 → 工具结果裁剪/摘要 → 最后才硬截断”;压缩时优先删寒暄和重复工具输出,绝不删系统规则、用户约束、ID、数字和未解决问题。

展开回答:

我的上下文预算大致是:

System/规则 10% | 任务状态 15% | 检索证据 35% |
对话历史 25% | 输出预留 15%

到 75% 左右开始压缩,不等溢出。

具体手段:

  1. 结构化任务状态。用一个 JSON 维护目标、约束、已完成、待办、失败记录,替代把几十轮对话全塞进去。状态比历史便宜,也更准。
  2. 滚动摘要。老的对话轮次做摘要,保留最近 N 轮和关键轮次(用户纠正、决策、关键实体)。
  3. 工具结果裁剪。原始结果存外部,只把摘要和引用放上下文;日志只保留错误窗口、关键行和 trace_id,不要整段贴。
  4. 按需加载。常驻摘要 + 索引,证据用的时候再取;大文件调研丢给子 Agent,只回传摘要。
  5. Prompt Cache。System、工具定义、固定规则放最前且顺序稳定,变化内容放后面。
  6. 硬截断兜底。最后才按 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。覆盖要分层:常见场景、长尾场景、边界条件、对抗输入、工具失败、无答案(应该拒答)。

口径五件套。 每条数据包含:

  1. Query:真实用户问题,脱敏后使用;
  2. Gold Answer:金标准答案,允许多种等价表达;
  3. Gold Evidence:回答问题所需的最小证据 chunk ID,用于算 Recall@k;
  4. Expected Tools:期望的工具序列或工具集合,用于算工具选择准确率;
  5. Rubric:评分维度和规则,比如正确性 0/1/2、忠实度、完整性、格式、安全。

评分规则要写死。 比如:

  • 正确性:0 完全错,1 部分对,2 完全对;
  • 忠实度:结论必须有证据支撑,编造直接 0;
  • 完整性:是否覆盖问题的所有子项;
  • 格式:是否符合 Schema;
  • 拒答:该拒答时拒答算对,不该拒答时拒答算错。

流程。 先标 50 条做校准,开会对齐口径;然后双标,算一致率/Cohen's kappa;分歧由第三人仲裁;定期复审,防止标注漂移。

维护。 评测集版本化;每次改 Prompt、换模型、加工具都跑回归;线上 bad case 定期回流,但不把测试集样本直接拿去调 Prompt,避免过拟合。

工具选择怎么算。 期望工具是集合就用集合匹配;有依赖顺序就用序列匹配;不要求顺序的,不因为顺序不同判错。

加分句:评测集的质量,决定了你后面所有优化的方向;口径不清,指标越高越危险。


六、手撕 ReAct(Q19-Q20) ​

Q19. 手撕:手写一个 ReAct 循环,要有最大步数,工具抛异常要能兜住。 ​

一句话答案:核心是“状态机 + 最大步数 + 工具异常转 observation + 未知工具提示 + 每次调用可追踪”;不是让模型自由发挥,而是让它在约束里循环。

完整代码(可直接运行):

python
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))

代码要点说明:

  1. 最大步数:max_steps 是硬上限,到了就返回 max_steps 状态,不编答案,交给上层降级或人工。
  2. 工具异常兜底:ToolRegistry.execute 捕获 Exception,把异常转成 {"ok": False, "error": ...} 的 observation,循环继续,模型可以根据错误换工具/换参数。
  3. 未知工具:不执行,直接回一条带 hint 的 observation,避免模型继续幻觉。
  4. 参数校验:至少校验 required 字段;生产环境用 jsonschema 或 Pydantic 做完整校验。
  5. 可追踪:每一步记录工具、参数、结果、耗时,带 trace_id,可以回放。
  6. 生产还要加:工具超时、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 调大”来解决重复调用;那只是让死循环跑得更久。要在循环里显式识别重复、给出反馈、强制收敛。


面试收尾:三句话总结 ​

  1. Agent 选型:默认写死,只有“路径不确定、结果可复核”的长尾场景才用 Agent;高危动作永远保留人工确认。
  2. 工具工程:工具描述是 Prompt 的一部分;工具多了要分组、路由、按需注入,不是一次性全塞。
  3. 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
模型重复调同一工具怎么办?计数 + 第二次提示 + 第三次拦截 + 轮询白名单 + 最大步数兜底