Agent:面试速记 + 从零学习
这份文档分两层用:
| 场景 | 读哪里 |
|---|---|
| 面试前 10 分钟 | 第一部分速记页:一张总图 + 高频考点表 |
| 面试前一天 | 第二部分易混对照 + 第三部分口述稿,出声念一遍 |
| 平时学习 | 第四部分逐个机制详解,按一次执行的数据流排的 |
| 想动手 | 第五部分:可运行实验 + 排查手册 |
| 检验是否真会 | 第六部分闭卷自测 |
全文围绕一个问题:你说一句话,模型怎么变成”它自己动手把活干完”——每一步谁在做决定,哪一步会出错。
Agent 面试的追问几乎都落在”谁在做决定”和”失败了怎么办”上。
配套代码在
projects/crossbuild-agent/,本文所有代码片段都在那份实现里跑过。
第一部分 速记页
0.1 一次 Agent 执行的完整旅程
这张图是全文骨架,后面每一节都在补其中一个环节。
flowchart TB
A["你:把这个项目编译到 ARM64"] --> B["你的程序:拼 messages 数组<br/>system + user + 工具清单"]
B --> C["HTTP 请求发给模型"]
C --> D{"模型返回什么?"}
D -->|"content 文本"| E["结束,把文本给用户"]
D -->|"tool_calls"| F["模型说:我要调 run_command<br/>参数是 cmake --build ."]
F --> G["你的程序执行这个命令(不是模型)<br/>模型碰不到你的机器"]
G --> H["拿到 stdout/stderr/exit code"]
H --> I["截断、包装成 role=tool 消息<br/>塞回 messages 数组"]
I --> J{"步数超了吗?<br/>同一个错重复了吗?"}
J -->|"没有"| C
J -->|"超了"| K["强制终止,输出诊断"]
看图,记五句话:
- 模型不执行任何东西。 它只是返回一段”我想调用
run_command,参数是这些”的结构化文本。真正执行的是你的代码。这是全场最容易答错的一点。 - 模型是无状态的。 它不记得上一轮,所谓”多轮对话”是你每次把完整历史重新发过去。
- 循环的本质就是这张图的 C→D→F→G→I→C。 所谓 Agent Loop,去掉包装就是一个 while。
- 工具执行层是你的地盘,也是唯一能出真事故的地方——删文件、跑死循环、烧光 token 都在这一步。
- 出问题先定位在这张图的哪一段:是模型没选对工具(提示/schema 问题),还是工具执行失败(工程问题),还是循环停不下来(控制问题)。
0.2 四层概念,别混为一谈
| 层 | 是什么 | 谁实现 | 典型故障 |
|---|---|---|---|
| LLM 调用 | 发一段文本,返回一段文本 | 模型服务商 | 超时、限流、内容被拒 |
| Tool Calling | 模型返回”我想调哪个工具、参数是什么” | 模型服务商提供格式,你负责执行 | 参数不合法、选错工具、幻觉出不存在的工具 |
| Agent Loop | 把工具结果喂回去,让模型决定下一步 | 你写的循环 | 死循环、步数爆炸、token 烧光 |
| Agent | Loop + 工具集 + 提示 + 状态 + 权限 | 你的整个系统 | 做错事且没人拦住 |
面试里说”我做过 Agent”,对方真正想知道的是后两层你做到了什么程度。前两层是调 API,谁都会。
0.3 高频考点一页表
面试前只看这张。第三列是最容易答错的地方。
Tool Calling
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| Tool Calling 和普通 Prompt 的区别 | 普通 Prompt 返回自由文本,你得自己正则解析;Tool Calling 由模型按你给的 JSON Schema 返回结构化参数,且模型被训练过何时该调 | 不是”Tool Calling 更聪明”,是输出格式有保证 |
| 模型执行工具吗 | 不执行。它只返回 tool_calls,执行的是你的代码 | 这题答错基本就废了 |
| 为什么参数要用 JSON Schema | 给模型划定参数的名字、类型、必填项,同时你能在执行前做校验;很多服务端还会据此做约束解码 | 不只是”文档作用” |
| 模型返回的参数不合法怎么办 | 不要抛异常中断,包装成一条工具失败消息塞回去,让模型自己改;同时记录,这是 Bad Case 来源 | 不要 retry 同样的调用 |
| 工具描述该怎么写 | 写清楚什么时候该用和什么时候不该用,比写清楚”是什么”更重要 | 描述写成参数说明是常见错误 |
| 一次返回多个 tool_calls 怎么办 | 可以并行执行无依赖的,但有副作用的要串行;每个结果都要单独回一条 role: tool 且 tool_call_id 对上 | id 对不上模型会错乱 |
| 工具太多怎么办 | 超过 20 个左右模型选择质量明显下降;做分组 + 按阶段动态注入,或加一层路由 | 不是全塞进去就行 |
Agent Loop
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| Agent Loop 最小实现 | while step < MAX: 调模型 → 有 tool_calls 就执行并回填 → 没有就结束 | 去掉框架就这几行 |
| 怎么防死循环 | 四道闸:步数上限、重复调用检测(同工具同参数连续 N 次)、成本上限(累计 token)、超时(整体墙钟) | 只说”设个最大步数”不够 |
| 什么时候该停 | 模型不再返回 tool_calls / 触发任一闸 / 任务有客观完成信号(编译通过、测试全绿) | 有客观信号的任务最好做,因为不用模型自评 |
| ReAct 是什么 | Reason + Act:让模型先写一段推理再选工具。现在主流模型内置了这个行为,不必再手写 Thought/Action 模板 | 不要把 ReAct 讲成必须的提示词格式 |
| Plan-Execute 和 ReAct 区别 | ReAct 走一步看一步;Plan-Execute 先出完整计划再逐条执行。长任务用 Plan 省 token,易变环境用 ReAct 更稳 | 不是谁更先进 |
| 单 Agent 还是多 Agent | 多 Agent 的收益是上下文隔离(各自只看自己那摊),代价是通信开销和调试难度。能单就单 | 不是”多个更强” |
| 子 Agent 什么时候值 | 子任务上下文很大且结果可以压成一句话时(比如”在代码库里找到那个函数”) |
工具执行层(你的主场)
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 命令执行怎么做安全 | 白名单优于黑名单;路径沙箱用解析后比前缀,不能只看字符串里有没有 ..;危险操作走人工确认 | 黑名单永远列不全 |
| 超时怎么做 | subprocess 带 timeout,超时要真的 kill 掉进程组(子进程会漏),返回可读信息而不是 traceback | 只 raise 不 kill 会留僵尸 |
| 输出十万行怎么办 | 双限制(字节 + 行数,谁先到算谁)、保留尾部(报错都在末尾)、完整输出落盘并把路径告诉模型,让它需要时自己去读 | 简单头尾截断会丢关键信息 |
| 工具失败要抛异常吗 | 不要。包装成文本结果回给模型,让它判断下一步。主循环只处理”框架级”错误 | 这是 Agent 和普通程序最大的思维差异 |
| 工具要不要幂等 | 重试和恢复都依赖幂等。写操作要么幂等,要么带执行记录 + 去重键 | 不然重跑会发两遍邮件 |
| 怎么做可观测 | 每步落一条记录:时间、工具、参数、结果摘要、耗时、token。出问题能回放整条决策链 | 只打印日志不叫可观测 |
上下文与成本
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 上下文满了怎么办 | 三招:截断(丢最老的)、压缩(把旧轮次总结成一段)、外置(存文件/DB,用工具去取) | 直接丢会丢掉任务目标 |
| 压缩要保住什么 | 任务目标、已确认的关键事实、未完成的待办。中间那些失败的尝试可以丢 | |
| token 怎么省 | 工具结果截断、历史压缩、prompt caching、能用小模型的环节用小模型 | |
| 为什么 Agent 比 Chat 贵得多 | 每一步都要把完整历史重发一遍,N 步就是 O(N²) 的累计输入量 | 这题答出来很加分 |
| prompt caching 怎么用 | 把不变的前缀(system + 工具定义)放最前面,变化的放后面,命中缓存能省大部分输入费用 | 前缀一变缓存就失效 |
身份类问题
| 问题 | 怎么答 |
|---|---|
| 你有几年 AI 经验 | 不接”几年”这个框:AI 应用是近期在做,项目和数字在这;但 Agent 落地真正卡住的是工具执行、失败恢复、状态管理,这部分我有多年工程积累。然后立刻举例 |
| 为什么从 C++/后端转 AI | 做模型部署/工具链时一直在这条链路边上,想往应用层走一层。有项目佐证,别说”看好前景” |
| 你这个项目最难的地方 | 选一个有具体数字的技术点展开,不要说”调 prompt 很难” |
第二部分 易混对照
每组都是面试里被追问到一半才发现自己没分清的地方。
2.1 “模型调用工具” vs “你调用工具”
| 真实情况 | |
|---|---|
| 模型做的 | 返回一段 JSON:{"name": "run_command", "arguments": "{\"command\": \"cmake --build .\"}"} |
| 模型做不到的 | 碰你的文件、执行任何命令、访问你的网络 |
| 你做的 | 解析这段 JSON、决定要不要执行、执行、把结果塞回消息数组 |
这意味着权限完全在你手上。 “Agent 会不会 rm -rf 我的机器”这个问题的答案是:只有你写的代码会。模型连你有没有真的执行都不知道,除非你告诉它结果。
2.2 多轮对话 vs 记忆
| 是什么 | 代价 | |
|---|---|---|
| 多轮对话 | 你把之前所有消息重新发一遍 | 每轮输入 token 累加,长对话越来越贵 |
| 上下文压缩 | 把旧消息总结成一段再发 | 省钱,但丢细节 |
| 记忆(memory) | 把事实存到外部(文件/DB/向量库),需要时检索回来 | 不占上下文,但要设计”什么时候存、什么时候取” |
模型本身没有任何记忆。所有”它记得我”的体验都是工程做出来的。
2.3 Agent vs Workflow
| Agent | Workflow | |
|---|---|---|
| 下一步谁决定 | 模型 | 你预先写死的流程图 |
| 适合 | 步骤不确定、需要试错(编译报错修复) | 步骤固定(文档入库 → 切片 → 向量化) |
| 可预测性 | 差 | 好 |
| 成本 | 高(每步都要调模型) | 低 |
| 调试 | 难 | 容易 |
面试加分点:生产系统里绝大部分”AI 应用”其实是 Workflow 加几个模型节点,真正需要 Agent 自主决策的环节很少。能说出”我在哪几步用了 Agent、哪几步固定死了、为什么”,比吹自主性强得多。
2.4 RAG vs 长上下文 vs 微调
| 手段 | 解决什么 | 什么时候选 |
|---|---|---|
| RAG | 模型不知道你的私有资料 | 资料多、会变、要引用出处 |
| 长上下文直接塞 | 同上,但资料少 | 资料能塞进窗口且不常变,最简单 |
| 微调 | 模型不会某种输出风格或任务格式 | 知识类问题不要用微调解决 |
常见误区是”模型答不准就去微调”。知识缺失用 RAG,格式/风格问题才用微调。
2.5 幻觉的三种来源
| 来源 | 表现 | 怎么治 |
|---|---|---|
| 没有依据 | 编造事实 | RAG 给依据 + 要求带出处 + 允许回答”不知道” |
| 有依据但没检索到 | 答非所问 | 改检索:切片、rerank、query 改写 |
| 检索到了但被淹没 | 关键信息在超长上下文中间被忽略 | 压上下文、把关键信息放首尾 |
面试问”怎么降幻觉”,只答”写好 prompt”是最低分。先分类再对症是标准答法。
2.6 temperature / top_p / seed
| 参数 | 作用 | Agent 场景怎么设 |
|---|---|---|
| temperature | 采样随机性,越高越发散 | 工具调用和结构化输出用 0 或接近 0 |
| top_p | 从累计概率前 p 的候选里采样 | 一般不和 temperature 同时调 |
| seed | 固定随机种子 | 做评测时设上,但不保证完全可复现(服务端批处理会影响) |
“评测结果不稳定怎么办”的答案不是只设 seed,而是多跑几次取统计量。
2.7 同步 / 流式 / 异步
| 模式 | 用在哪 | 注意 |
|---|---|---|
| 一次性返回 | 后台批处理、评测 | 最简单 |
| 流式(SSE) | 前端要逐字显示 | 工具调用的参数也是流式拼出来的,要等拼完才能执行 |
| 异步任务 | 单次执行几分钟以上(编译、批量处理) | 要有任务 ID、状态查询、断线重连 |
Agent 类产品几乎都需要第三种,因为一次执行动辄几十秒到几分钟。
第三部分 口述稿
出声念,每段 30-60 秒。面试答题的节奏是先给结论,再给一个具体例子,最后说边界。
3.1 Tool Calling 是什么
“Tool Calling 是模型把’我想干什么’变成结构化输出的机制。我给模型一份工具清单,每个工具用 JSON Schema 描述参数;模型在回复里返回 tool_calls,告诉我它想调哪个工具、参数是什么。模型本身不执行任何东西,执行的是我的代码,我拿到结果再包成一条 tool 消息塞回对话历史,模型接着往下推。
跟自己写 prompt 让模型输出 JSON 再正则解析比,区别是输出格式有保证,而且模型被专门训练过什么时候该调工具、什么时候直接回答。“
3.2 Agent Loop 怎么跑
“去掉框架包装就是一个 while 循环:调模型,看返回里有没有 tool_calls,有就执行、把结果塞回 messages 再调一次,没有就说明它认为任务完成了,退出。
关键不在循环本身,在什么时候必须强制停下来。我设了四道闸:步数上限、同工具同参数连续重复检测、累计 token 成本上限、整体墙钟超时。我那个交叉编译的项目里,同一个编译错误重复三次就判定修不好了,直接终止并输出诊断报告,而不是让它一直烧 token。“
3.3 工具调用失败了怎么办
“分两类。一类是工具本身执行失败——文件不存在、命令超时、参数不合法——这类不能抛异常中断循环,要包装成一条可读的文本结果回给模型,让它自己判断下一步。模型通常会换个路径或换个做法。
另一类是框架级错误——模型服务超时、限流、网络断——这类才由主循环处理,走重试或降级。
这个区分很重要:对 Agent 来说,工具失败是正常的信息输入,不是异常。我在实现里所有工具异常都统一包装,主循环只处理框架错误。“
3.4 编译日志十万行怎么塞进上下文
“不能直接塞,也不能简单截断。我的做法是三层:
第一,双限制,字节数和行数谁先触发算谁,因为有时是几行超长,有时是几万行短的。
第二,保留尾部而不是头尾各半,编译报错基本都在最后。
第三,也是关键的一点,截掉的完整输出落盘到临时文件,把路径写在返回给模型的提示里,比如’保留第 2140 到 2200 行,共 98431 行,完整输出在 /tmp/xxx.log’。这样模型知道自己漏了多少,需要细节时可以用读文件工具自己去取。这就把截断从’丢信息’变成了’分层访问’。“
3.5 怎么防止 Agent 做危险操作
“权限完全在我这边,因为模型只是返回一段’我想执行 rm -rf’的文本,执行不执行是我的代码决定的。
我做了三层:命令白名单,只允许 cmake、make、ninja、nm、ldd 这些,其余一律拒绝并告诉模型原因——白名单优于黑名单,黑名单永远列不全。路径沙箱,所有文件操作解析成绝对路径后比对工作目录前缀,越界直接拒绝,不能只检查字符串里有没有 ..,因为软链接和绝对路径都能绕过。超时终止,命令超时真的 kill 掉,返回可读信息。
再往上还有一层是人工确认,高危操作停下来问人,我这个项目里暂时没做,因为工具集本身已经是只读加构建。“
3.6 为什么 Agent 比聊天贵那么多
“因为每一步都要把完整历史重发一遍。第一步发 1 千 token,第二步要把第一步的问答加进去发 2 千,第十步可能就是 2 万。整个任务的累计输入量大概是步数的平方级。
所以省钱的地方主要在三处:工具结果要截断,别把十万行日志整个塞进历史;历史到一定长度要压缩,把旧轮次总结成一段;把 system prompt 和工具定义这些不变的前缀放最前面吃 prompt caching。前缀一变缓存就失效,所以工具清单不能动态乱插。“
3.7 你怎么知道它变好了
“靠评测集,不靠感觉。我这个项目的特别之处是成功信号是客观的——编译通过或者不通过,机器判定,不需要人打分。我选了 20 个开源 C/C++ 项目按依赖复杂度分成三档,写了跑批脚本,一次跑完输出成功率、平均步数、平均 token。
每次改动只改一个变量,跑一轮记一次数字。比如加 RAG 检索之前成功率是多少、之后是多少,切片按段落切和按报错条目切分别是多少。最后我还把失败的案例归了类,能说清楚剩下的错集中在哪几种、为什么难修。
大多数 RAG 项目的准确率是人工打分的,主观,我这个不是。“
3.8 单 Agent 还是多 Agent
“我用单 Agent。多 Agent 的真正收益是上下文隔离——子任务自己看一大堆资料,只把结论返回给主 Agent,主上下文不被污染。代价是通信开销、调试困难、失败面变大。
我这个任务的步骤是线性的:配置、编译、读错、改、重试,没有需要并行探索的子任务,上不上多 Agent 都一样,所以选简单的。如果将来要同时评估多个三方库的适配方案,那时候子 Agent 才有意义。“
3.9 为什么用 LangGraph 不自己写循环
“我是先自己手写了一遍才换的框架,这样知道框架替我做了什么。裸写的循环大概几十行就能跑,但缺三样东西:状态持久化(跑到一半中断要能续)、分支和条件跳转(不同报错类型走不同修复路径)、事件流(前端要看到每一步)。
LangGraph 把这些做成了状态机的节点和边,省了我自己实现。但它不是必需的,任务简单的时候自己写更清楚,我第一版就是自己写的。“
3.10 上生产还缺什么
“现在是个能跑通的单机版,上生产至少还缺四样:并发,现在一次只能跑一个任务,要做任务队列和 worker 池;结果缓存,同一个项目重复跑应该命中缓存;多模型 fallback,模型服务挂了或限流要能切备用;成本和配额管理,按用户或按任务限额,不然一个死循环能烧掉很多钱。
另外可观测还要再往前走一步,现在是本地 SQLite 记录,生产要接 tracing,能按 trace id 串起整条决策链。“
第四部分 逐个机制详解
按一次执行的数据流排序。零基础从这里开始读,前三部分回头再看。
4.1 起点:模型只是一个文本函数
把大模型想成一个函数:
输入:一段文本
输出:一段文本
就这样。它不联网、不执行代码、不记得你、没有副作用。你每次调用它,它都像第一次见你。
这个认知是后面一切的基础。当你听到”Agent 自己上网查了资料""它记住了我的偏好""它执行了命令”,真实情况永远是:有人写代码,把这些能力接到了这个函数外面。
那”多轮对话”是怎么来的?你每次把之前所有的话重新发一遍:
sequenceDiagram
participant U as 你的程序
participant M as 模型
U->>M: 用户:你好
M->>U: 你好,有什么可以帮你
Note over U: 程序把这两句存下来
U->>M: 用户:你好 / 助手:你好... / 用户:刚才我说什么了
M->>U: 你说了 你好
第二次请求里,模型能”记得”,仅仅是因为你把第一轮的内容又发了一遍。模型端什么都没存。
这解释了三件事:为什么长对话越来越贵(每轮输入都在变长)、为什么有上下文窗口限制(一次能发的文本有上限)、为什么需要”记忆”系统(把事实存到外面,用的时候再捞回来)。
4.2 messages:对话的数据结构
实际发给模型的不是一整块文本,而是一个数组,每条有角色:
messages = [
{"role": "system", "content": "你是一个在本地工作目录里执行任务的助手"},
{"role": "user", "content": "看看这个目录里有什么"},
{"role": "assistant", "content": "目录里有 agent、eval、knowledge 三个子目录"},
]
| 角色 | 谁写的 | 作用 |
|---|---|---|
system | 你 | 设定行为、约束、背景。放在最前面 |
user | 用户 | 真实的用户输入 |
assistant | 模型 | 模型的回复。也可能包含 tool_calls |
tool | 你 | 工具执行结果,必须带 tool_call_id 对上是哪次调用 |
system 不是魔法,它只是一段被放在最前面、模型被训练成会更重视的文本。它管不住一个铁了心不听话的模型,所以安全边界不能建在 prompt 上,得建在代码里。
4.3 Tool Calling:一次完整往返
这是整个 Agent 的心脏。拆开看一次完整的往返。
第一步,你告诉模型有哪些工具。工具用 JSON Schema 描述:
tools = [{
"type": "function",
"function": {
"name": "run_command",
"description": "执行命令,只允许 cmake/make/ninja/nm/ldd/file/readelf/git/ls/cat,有超时限制",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "完整命令行,例如 cmake --version"},
"timeout": {"type": "integer", "description": "超时秒数,默认 60"},
},
"required": ["command"],
},
},
}]
description 比你想的重要。模型靠它决定什么时候用这个工具。写”执行命令”是不够的,要写清楚能执行什么、不能执行什么、什么场景该用。工具选错,八成是描述的问题。
第二步,模型返回它的意图:
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
msg = resp.choices[0].message
msg.content # None —— 它这轮不说话
msg.tool_calls # [ToolCall(id="call_abc", function=Function(
# name="run_command",
# arguments='{"command": "cmake --version"}'))]
注意 arguments 是字符串,不是字典,要自己 json.loads。而且它可能不是合法 JSON——模型偶尔会生成半截的、带注释的、多了逗号的。所以解析必须包在 try 里。
第三步,你执行,然后把结果塞回去:
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": "cmake version 4.0.2",
})
两条都要 append:模型的那条 assistant(带 tool_calls)和你的那条 tool。少了前者,模型会发现历史里凭空冒出一个工具结果,直接错乱。tool_call_id 必须对上,一轮返回多个调用时尤其重要。
第四步,再调一次模型,它看到结果后决定是继续调工具还是给出最终回答。
sequenceDiagram
participant P as 你的程序
participant M as 模型
participant S as 系统(文件/命令)
P->>M: messages + tools 定义
M-->>P: tool_calls: run_command(cmake --version)
Note over M: 模型到此为止,它什么都没执行
P->>P: 校验:命令在白名单吗?
P->>S: subprocess.run(cmake --version, timeout=60)
S-->>P: exit=0, stdout=cmake version 4.0.2
P->>P: 截断、包装
P->>M: messages + [assistant(tool_calls), tool(结果)]
M-->>P: 本机 cmake 是 4.0.2 版
4.4 JSON Schema:为什么值得认真写
Schema 有三个作用,面试常只答出第一个:
- 告诉模型参数长什么样——名字、类型、哪些必填、枚举值有哪些
- 让你能在执行前校验——类型不对、缺必填项,直接拒绝并回传错误,不用等执行炸掉
- 很多服务端据此做约束解码——在生成阶段就限制模型只能吐出符合 schema 的 token,从根上减少非法输出
实践中的几条经验:
- 枚举比自由字符串可靠得多。
{"enum": ["debug", "release"]}比{"type": "string", "description": "构建类型"}少一个数量级的错误 - 参数越少越好。八个可选参数的工具,模型经常漏填或乱填。拆成两个工具通常更稳
- 默认值写在 description 里,模型会照着填
- 必填项要真的必填。全是可选参数,模型会倾向什么都不填
4.5 Agent Loop:去掉包装就是一个 while
前面讲的是一次往返。Agent 就是把它放进循环:
for step in range(1, MAX_STEPS + 1):
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=SCHEMAS)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = call_tool(workdir, tc.function.name, args)
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
return f"达到最大步数 {MAX_STEPS},未完成"
这就是全部。 你在网上看到的所有 Agent 框架,核心都是这十几行,其余是状态管理、事件流、错误恢复、可观测的包装。
真正的难点不在这段代码,在怎么让它停下来。四道闸:
| 闸 | 防什么 | 怎么实现 |
|---|---|---|
| 步数上限 | 无限循环 | for step in range(MAX_STEPS) |
| 重复检测 | 反复试同一个错误做法 | 记录 (工具名, 参数) 的哈希,连续 N 次相同就终止 |
| 成本上限 | 烧钱 | 累加 resp.usage.total_tokens,超阈值终止 |
| 墙钟超时 | 整体卡死 | 循环外层记起始时间,每步检查 |
只答”设个最大步数”是不够的。重复检测是实战中最有用的一道——模型卡住时的典型表现不是乱跑,而是把同一个失败的命令换着法儿试。
4.6 工具执行层:唯一能出真事故的地方
模型不执行任何东西,所以所有风险都在这一层。这也是面试里最能体现工程功底的部分。
权限:白名单,不是黑名单
ALLOWED_COMMANDS = {"cmake", "make", "ninja", "nm", "ldd", "file", "readelf", "git", "ls", "cat"}
parts = command.split()
if parts[0] not in ALLOWED_COMMANDS:
raise ToolError(f"命令 {parts[0]} 不在白名单内,允许的命令: {sorted(ALLOWED_COMMANDS)}")
为什么不能用黑名单:你永远列不全。禁了 rm,还有 find -delete、dd、mv 到 /dev/null、python -c、shell 重定向。白名单的错误方向是”少了个工具用不了”,黑名单的错误方向是”漏了一个把机器毁了”。
注意上面这个实现用 split() 且不走 shell,所以 cmake --build . && rm -rf / 会被当成一整条命令的参数,&& 不会被解释。如果你用了 shell=True,白名单立刻失效,这是个常见漏洞。
路径沙箱:解析后比前缀
def _resolve(workdir: Path, path: str) -> Path:
target = (workdir / path).resolve()
if not str(target).startswith(str(workdir.resolve())):
raise ToolError(f"路径越界,只允许访问 {workdir} 内的文件")
return target
关键是 .resolve()——它会展开 ..、软链接、相对路径,拿到真实绝对路径再比对。只检查字符串里有没有 .. 是错的:/etc/passwd 没有 .. 但同样越界,而软链接可以指向任何地方。
超时:要真的杀掉
try:
proc = subprocess.run(parts, cwd=workdir, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise ToolError(f"命令超时({timeout}s)被终止: {command}")
subprocess.run 的 timeout 会 kill 掉直接子进程。但如果那个子进程又派生了孙进程(make -j8 就是),孙进程可能变成孤儿继续跑。生产环境要用进程组:start_new_session=True 起进程,超时时 os.killpg 整组端掉。
输出截断:从”丢信息”到”分层访问”
编译日志动辄几万行,直接塞进上下文会爆窗口,而且淹没关键信息。做法:
def _truncate(text: str, spill: bool = False) -> str:
lines = text.splitlines()
over_lines = len(lines) > MAX_LINES
over_bytes = len(text) > MAX_BYTES
if not (over_lines or over_bytes):
return text
kept = lines[-MAX_LINES:] if over_lines else lines
body = "\n".join(kept)
if len(body) > MAX_BYTES:
body = body[-MAX_BYTES:]
reason = "字节上限"
else:
reason = "行数上限"
start = len(lines) - len(kept) + 1
note = f"[保留第 {start}-{len(lines)} 行,共 {len(lines)} 行,触发{reason}]"
if spill:
fd, path = tempfile.mkstemp(prefix="crossbuild-", suffix=".log")
with os.fdopen(fd, "w") as f:
f.write(text)
note = f"[保留第 {start}-{len(lines)} 行,共 {len(lines)} 行,触发{reason}。完整输出: {path}]"
return f"{note}\n{body}"
三个设计点,每个都能单独被追问:
- 双限制:有时是几万行短日志,有时是几行超长(链接错误能一行几十 KB)。只看字节会留下几行没用的,只看行数会爆上下文
- 保留尾部:编译报错在最后。如果是别的场景(比如日志分析)可能要保留头部或两头,这是个场景相关的选择,面试可以主动说出来
- 溢出落盘 + 告诉路径:
完整输出: /tmp/crossbuild-xxx.log写进返回文本,模型需要细节时用read_file自己去取。这一条把截断从损失变成了分层
失败不抛异常:Agent 和普通程序最大的思维差异
def call_tool(workdir, name, args):
fn = REGISTRY.get(name)
if fn is None:
return f"错误:不存在名为 {name} 的工具"
try:
return fn(workdir, **args)
except ToolError as e:
return f"工具失败:{e}"
except TypeError as e:
return f"参数错误:{e}"
except Exception as e:
return f"未预期的错误:{type(e).__name__}: {e}"
所有异常都变成返回值。 普通程序里异常要往上抛让调用方处理;在 Agent 里,“调用方”是模型,而模型只能读文本。一条 工具失败:文件不存在: CMakeLists.txt 对模型是有效信息,它会去列目录找找看。抛异常中断循环,则整个任务直接死掉。
主循环只处理框架级错误:模型服务超时、限流、网络断。这两类要分清楚。
4.7 上下文管理:为什么长任务一定会撞墙
每一步都要重发完整历史,所以输入长度是这样涨的:
| 步数 | 本步输入 token(示意) | 累计输入 |
|---|---|---|
| 1 | 1,000 | 1,000 |
| 2 | 2,500 | 3,500 |
| 5 | 9,000 | 25,000 |
| 10 | 20,000 | 105,000 |
| 20 | 45,000 | 450,000 |
累计输入大致是步数的平方级。 这解释了两件事:为什么 Agent 比聊天贵一个数量级,以及为什么长任务迟早撞上上下文窗口。
三种应对,按代价从低到高:
flowchart LR
A[历史太长] --> B["截断<br/>丢最老的消息"]
A --> C["压缩<br/>旧轮次总结成一段"]
A --> D["外置<br/>存文件/DB,用工具取"]
B --> B1["最简单<br/>会丢任务目标"]
C --> C1["要额外调一次模型<br/>丢细节但保主线"]
D --> D1["不占上下文<br/>要设计存取时机"]
压缩时必须保住三样:任务目标(否则模型忘了在干嘛)、已确认的关键事实(已经查明的版本号、路径、结论)、未完成的待办。可以放心丢掉的是中间那些失败的尝试细节——但要保留”试过 X,不行,因为 Y”这个结论,否则模型会重新试一遍。
工程上的常见做法是:保留 system prompt + 最初的任务描述 + 最近 N 轮原文,中间部分总结成一条 assistant 消息。
4.8 状态与中断恢复
单次执行几十秒还好,跑几十分钟的任务(编译就是)必须考虑中断。要存什么:
| 存什么 | 为什么 |
|---|---|
| 完整 messages 数组 | 恢复时直接接着跑 |
| 当前步数、累计 token | 闸门计数不能重置 |
| 工作目录状态 | 文件已经改到哪一步了 |
| 每步的工具调用记录 | 复盘和 trace 用 |
存哪:小项目 SQLite 或 JSONL 就够。pi 那个项目用的是 JSONL 加 commit 语义(packages/agent/src/harness/session/),每步追加一行,天然支持回放和 fork。
恢复不是简单地接着跑,要先处理三件事:
- 上一步做到一半的工具调用:模型返回了 tool_calls 但你还没执行完就崩了。恢复时要么重新执行(要求工具幂等),要么标记为失败让模型重来
- 外部状态漂移:文件可能被人改过,之前检索到的结论可能失效
- 幂等性:只读工具随便重试;写操作要么天然幂等(写同样内容),要么带去重键
这块是传统后端的老问题,也是你的主场。面试里能讲清楚”跑到第 7 步崩了怎么办”的人不多。
4.9 成本与延迟
省 token 的地方,按收益排序:
| 手段 | 典型收益 | 代价 |
|---|---|---|
| 工具结果截断 | 很大 | 可能丢信息(用溢出落盘缓解) |
| prompt caching | 大(缓存命中部分大幅降价) | 前缀必须稳定 |
| 历史压缩 | 大 | 多一次模型调用 |
| 小模型做简单环节 | 中 | 要判断哪些环节能降级 |
| 减少工具数量 | 小 | 但能提高选择准确率 |
prompt caching 的关键是前缀稳定。把 system prompt、工具定义这些不变的放最前面,变化的放后面。如果你动态往工具清单里插东西,或者 system prompt 里塞了当前时间,缓存每次都失效。
延迟优化主要靠三点:并行执行无依赖的工具调用、流式输出让用户先看到进展、把慢工具(编译)做成异步任务加状态查询。
4.10 RAG 在 Agent 里的位置
RAG 单独展开够写一篇,这里只讲它和 Agent 的关系。
两种接法,区别很大:
flowchart TB
subgraph S1["接法一:固定前置检索(Workflow 式)"]
A1[用户问题] --> B1[检索知识库] --> C1[拼进 prompt] --> D1[模型回答]
end
subgraph S2["接法二:检索作为工具(Agent 式)"]
A2[任务] --> B2[模型判断] --> C2{需要查资料吗}
C2 -->|要| D2[调 search_knowledge 工具] --> B2
C2 -->|不要| E2[直接干活]
end
| 固定前置检索 | 检索作为工具 | |
|---|---|---|
| 成本 | 低,一次检索 | 高,可能多次 |
| 适合 | 问答类,每次都需要资料 | 任务类,只在卡住时需要 |
| 风险 | 不需要时也检索,引入噪声 | 模型可能该查不查 |
我那个交叉编译项目用的是第二种:只有编译报错、模型判断自己不认识这个错误时才去检索知识库,平时不查。这样省 token,也避免无关资料干扰。
面试可以主动说这个选择,因为大部分人只知道第一种。
第五部分 动手实验
代码都在 projects/crossbuild-agent/,本节每条都在本机跑过。
5.1 准备
pip install openai
export DEEPSEEK_API_KEY=sk-xxx
cd projects/crossbuild-agent/agent
5.2 实验一:看清 tool_calls 长什么样
在 mini_agent.py 的 run() 里,resp 拿到之后加一行打印,跑一次,观察:
msg.content是None,msg.tool_calls有值——模型这轮只想调工具,不说话tc.function.arguments是字符串不是 dict- 每个
tc.id形如call_xxx,回填时必须对上
这一步的目的是破除”模型执行了命令”的错觉。 你会清楚看到:模型只是返回了一段 JSON,subprocess.run 是你自己调的。
5.3 实验二:把工具层的四道边界逐个撞一遍
import sys; sys.path.insert(0, 'agent')
from pathlib import Path
import tools
w = Path('.').resolve()
tools.read_file(w, '../../../etc/passwd') # 路径越界 → ToolError
tools.run_command(w, 'rm -rf /') # 不在白名单 → ToolError
tools.run_command(w, 'cat /dev/zero', timeout=2) # 超时 → 被 kill
tools.run_command(w, 'cat agent/tools.py') # 触发截断 → 保留尾部 + 溢出文件路径
实测输出:
路径越界,只允许访问 /Users/like/cpp-learning/CppAi/projects/crossbuild-agent 内的文件
命令 rm 不在白名单内,允许的命令: ['cat', 'cmake', 'file', 'git', 'ldd', 'ls', ...]
命令超时(2s)被终止: cat /dev/zero
[保留第 36-135 行,共 135 行,触发行数上限。完整输出: /var/folders/.../crossbuild-xxx.log]
每一条都对应一个面试题,自己跑一遍比背十遍管用。
5.4 实验三:故意制造失败,看模型怎么恢复
让它读一个不存在的文件:
python mini_agent.py "读一下 NOTEXIST.md 的内容" ..
观察模型收到 工具失败:文件不存在: NOTEXIST.md 之后做了什么——通常会去 list_files 找找看有没有类似的文件。这就是”工具失败包装成返回值”的价值:模型能自己恢复。
然后把 call_tool 里的 except ToolError 那行注释掉,让异常真的抛出来,再跑一次——整个程序崩掉。对比一下,这个差异就是第三部分 3.3 那段口述稿的实证。
5.5 实验四:把闸门调小,看它怎么停
把 MAX_STEPS 改成 2,给一个需要四五步的任务,观察返回 达到最大步数 2,未完成。
然后加一个重复检测:记录每次 (工具名, 参数) 的哈希,连续三次相同就退出。给它一个死活修不好的任务试试——你会看到模型换着法儿试同一个命令,这就是实战里最常见的卡死方式。
5.6 排查手册
| 现象 | 先查哪里 |
|---|---|
| 模型不调工具,直接瞎答 | 工具 description 写清楚”什么时候该用”了吗;system prompt 有没有明确说可以用工具 |
| 模型调了不存在的工具 | 工具名拼写;是不是历史里有过别的工具定义 |
| 参数总是缺字段 | 必填项是不是真的标了 required;参数是不是太多了,考虑拆工具 |
| 报 tool_call_id 不匹配 | 是不是漏 append 了那条 assistant 消息;多个 tool_calls 是不是只回了一条 |
| 上下文爆了 | 工具结果截断了吗;历史压缩做了吗 |
| token 烧得飞快 | 打印每步的 resp.usage,看是输入涨了还是步数太多 |
| 同一个错反复试 | 加重复检测闸;把失败原因写得更明确一点回给模型 |
| 跑一半卡住不动 | 有没有整体墙钟超时;工具本身有没有超时 |
第六部分 闭卷自测
合上文档,能说出来才算会。每题后面是对应章节。
基础
- 模型会执行你的命令吗?那”Agent 执行了 rm -rf”这句话哪里不准确?(4.1 / 4.3)
- 模型有记忆吗?“多轮对话”是怎么实现的?(4.1)
messages里四种角色分别是谁写的?tool消息为什么必须带 id?(4.2)tool_calls里的arguments是什么类型?为什么解析要包 try?(4.3)
Tool Calling
- Tool Calling 相比”让模型输出 JSON 再正则解析”,好在哪两点?(3.1)
- 工具的
description最该写什么?(4.3) - 为什么枚举比自由字符串可靠?参数多了会怎样?(4.4)
- 模型返回的参数不合法,你怎么处理?为什么不能重试同样的调用?(0.3 / 4.6)
Loop 与控制
- 手写一个最小 Agent Loop,十行以内。(4.5)
- 防死循环的四道闸分别是什么?实战中哪道最有用,为什么?(4.5)
- 什么样的任务最适合做成 Agent?为什么”有客观成功信号”这么重要?(0.3 / 2.3)
- Agent 和 Workflow 怎么选?生产系统里哪个更多?(2.3)
工具执行
- 为什么权限要用白名单?用了
shell=True会发生什么?(4.6) - 路径沙箱为什么不能只检查字符串里有没有
..?(4.6) subprocess的 timeout 能杀掉孙进程吗?不能的话怎么办?(4.6)- 十万行编译日志怎么处理?三个设计点分别是什么?(3.4 / 4.6)
- 为什么工具失败不能抛异常?哪类错误才该由主循环处理?(3.3 / 4.6)
上下文与成本
- 为什么 Agent 的累计输入是步数的平方级?(4.7 / 4.9)
- 上下文满了三种应对分别是什么?压缩时必须保住哪三样?(4.7)
- prompt caching 怎么才能命中?什么操作会让它失效?(4.9)
状态与工程
- 跑到第 7 步崩了,恢复时要处理哪三个问题?(4.8)
- 哪些工具需要幂等?不幂等会出什么事?(4.8)
- 单 Agent 和多 Agent 怎么选?多 Agent 的真正收益是什么?(3.8)
- RAG 接进 Agent 有哪两种方式?各适合什么场景?(4.10)
项目与表达
- 你怎么证明你的系统变好了?(3.7)
- 上生产还缺什么?(3.10)
- 被问”你有几年 AI 经验”,怎么答?(0.3)
附:这份文档和 ai/ 目录的关系
ai/ 下有 49 篇分专题的详细资料(LLM 基础、Prompt、RAG、Agent、Eval、部署、框架),
这篇是贯通版:按一次执行的数据流把它们串起来,适合从零建立整体认知。
深挖某个专题时回到 ai/ 对应目录:
- Tool Calling 细节 →
ai/04-Agent与工具调用/Tool Calling.md - Agent 状态管理 →
ai/04-Agent与工具调用/Agent规划与状态管理.md - 权限与 Schema →
ai/04-Agent与工具调用/工具权限与参数Schema.md - RAG 全链路 →
ai/03-RAG/ - 评测 →
ai/05-Eval与观测/
想看生产级实现长什么样,读 third_party/pi:
packages/agent/src/agent-loop.ts(803 行)——工业级的 Looppackages/agent/src/harness/tools/bash.ts(145 行)——命令执行与截断,本文 4.6 的做法就是从这儿学的packages/agent/src/harness/execution/effect-gate.ts(64 行)——中断与取消的状态机