PERSONAL LAB / interview/从零开始图解
Interview 从零开始图解

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["强制终止,输出诊断"]

看图,记五句话:

  1. 模型不执行任何东西。 它只是返回一段”我想调用 run_command,参数是这些”的结构化文本。真正执行的是你的代码。这是全场最容易答错的一点。
  2. 模型是无状态的。 它不记得上一轮,所谓”多轮对话”是你每次把完整历史重新发过去。
  3. 循环的本质就是这张图的 C→D→F→G→I→C。 所谓 Agent Loop,去掉包装就是一个 while。
  4. 工具执行层是你的地盘,也是唯一能出真事故的地方——删文件、跑死循环、烧光 token 都在这一步。
  5. 出问题先定位在这张图的哪一段:是模型没选对工具(提示/schema 问题),还是工具执行失败(工程问题),还是循环停不下来(控制问题)。

0.2 四层概念,别混为一谈

是什么谁实现典型故障
LLM 调用发一段文本,返回一段文本模型服务商超时、限流、内容被拒
Tool Calling模型返回”我想调哪个工具、参数是什么”模型服务商提供格式,你负责执行参数不合法、选错工具、幻觉出不存在的工具
Agent Loop把工具结果喂回去,让模型决定下一步你写的循环死循环、步数爆炸、token 烧光
AgentLoop + 工具集 + 提示 + 状态 + 权限你的整个系统做错事且没人拦住

面试里说”我做过 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: tooltool_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

AgentWorkflow
下一步谁决定模型你预先写死的流程图
适合步骤不确定、需要试错(编译报错修复)步骤固定(文档入库 → 切片 → 向量化)
可预测性
成本高(每步都要调模型)
调试容易

面试加分点:生产系统里绝大部分”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 有三个作用,面试常只答出第一个:

  1. 告诉模型参数长什么样——名字、类型、哪些必填、枚举值有哪些
  2. 让你能在执行前校验——类型不对、缺必填项,直接拒绝并回传错误,不用等执行炸掉
  3. 很多服务端据此做约束解码——在生成阶段就限制模型只能吐出符合 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 -deleteddmv/dev/nullpython -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}"

三个设计点,每个都能单独被追问:

  1. 双限制:有时是几万行短日志,有时是几行超长(链接错误能一行几十 KB)。只看字节会留下几行没用的,只看行数会爆上下文
  2. 保留尾部:编译报错在最后。如果是别的场景(比如日志分析)可能要保留头部或两头,这是个场景相关的选择,面试可以主动说出来
  3. 溢出落盘 + 告诉路径完整输出: /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(示意)累计输入
11,0001,000
22,5003,500
59,00025,000
1020,000105,000
2045,000450,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。

恢复不是简单地接着跑,要先处理三件事:

  1. 上一步做到一半的工具调用:模型返回了 tool_calls 但你还没执行完就崩了。恢复时要么重新执行(要求工具幂等),要么标记为失败让模型重来
  2. 外部状态漂移:文件可能被人改过,之前检索到的结论可能失效
  3. 幂等性:只读工具随便重试;写操作要么天然幂等(写同样内容),要么带去重键

这块是传统后端的老问题,也是你的主场。面试里能讲清楚”跑到第 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.pyrun() 里,resp 拿到之后加一行打印,跑一次,观察:

  • msg.contentNonemsg.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,看是输入涨了还是步数太多
同一个错反复试加重复检测闸;把失败原因写得更明确一点回给模型
跑一半卡住不动有没有整体墙钟超时;工具本身有没有超时

第六部分 闭卷自测

合上文档,能说出来才算会。每题后面是对应章节。

基础

  1. 模型会执行你的命令吗?那”Agent 执行了 rm -rf”这句话哪里不准确?(4.1 / 4.3)
  2. 模型有记忆吗?“多轮对话”是怎么实现的?(4.1)
  3. messages 里四种角色分别是谁写的?tool 消息为什么必须带 id?(4.2)
  4. tool_calls 里的 arguments 是什么类型?为什么解析要包 try?(4.3)

Tool Calling

  1. Tool Calling 相比”让模型输出 JSON 再正则解析”,好在哪两点?(3.1)
  2. 工具的 description 最该写什么?(4.3)
  3. 为什么枚举比自由字符串可靠?参数多了会怎样?(4.4)
  4. 模型返回的参数不合法,你怎么处理?为什么不能重试同样的调用?(0.3 / 4.6)

Loop 与控制

  1. 手写一个最小 Agent Loop,十行以内。(4.5)
  2. 防死循环的四道闸分别是什么?实战中哪道最有用,为什么?(4.5)
  3. 什么样的任务最适合做成 Agent?为什么”有客观成功信号”这么重要?(0.3 / 2.3)
  4. Agent 和 Workflow 怎么选?生产系统里哪个更多?(2.3)

工具执行

  1. 为什么权限要用白名单?用了 shell=True 会发生什么?(4.6)
  2. 路径沙箱为什么不能只检查字符串里有没有 ..?(4.6)
  3. subprocess 的 timeout 能杀掉孙进程吗?不能的话怎么办?(4.6)
  4. 十万行编译日志怎么处理?三个设计点分别是什么?(3.4 / 4.6)
  5. 为什么工具失败不能抛异常?哪类错误才该由主循环处理?(3.3 / 4.6)

上下文与成本

  1. 为什么 Agent 的累计输入是步数的平方级?(4.7 / 4.9)
  2. 上下文满了三种应对分别是什么?压缩时必须保住哪三样?(4.7)
  3. prompt caching 怎么才能命中?什么操作会让它失效?(4.9)

状态与工程

  1. 跑到第 7 步崩了,恢复时要处理哪三个问题?(4.8)
  2. 哪些工具需要幂等?不幂等会出什么事?(4.8)
  3. 单 Agent 和多 Agent 怎么选?多 Agent 的真正收益是什么?(3.8)
  4. RAG 接进 Agent 有哪两种方式?各适合什么场景?(4.10)

项目与表达

  1. 你怎么证明你的系统变好了?(3.7)
  2. 上生产还缺什么?(3.10)
  3. 被问”你有几年 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 行)——工业级的 Loop
  • packages/agent/src/harness/tools/bash.ts(145 行)——命令执行与截断,本文 4.6 的做法就是从这儿学的
  • packages/agent/src/harness/execution/effect-gate.ts(64 行)——中断与取消的状态机
Related · 从零开始图解
⎇ main interview/从零开始图解 54 节 253 notes UTF-8