1 Agent 的本质:循环、状态与工具
一次模型调用与一个 agent 的分界线在哪?为什么「多轮对话」不等于「有状态」?循环靠什么停下来,状态该存哪几层,工具为什么要有契约而不是一个函数名?当模型反复调用同一个工具、观察却不变时,宿主应该在第几步介入?
用「闭环、工具、状态」三个判据判断一个系统是不是 agent;画出五阶段循环与四层状态;为工具写出带前置条件、副作用、可逆性与幂等键的契约;用预算向量与进展度量设计终止条件;跑通一个 120 行的最小内核,并从 JSONL trace 里定位死循环。
先说结论:agent 不是「更长的 prompt」,而是一个带状态、带工具、带预算的闭环控制器。模型只是控制器里的策略函数:它读当前状态,产出一个动作意图;真正执行动作、改变世界、记录结果的是宿主。把这句话拆开,就得到本书后续十三章的全部主题——协议是动作的语法,沙箱是动作的边界,上下文是状态的投影,评估是控制器的验收,可观测性是控制器的黑匣子。
为什么这件事值得单独写一本书?因为 2023 年以来的多数 agent 事故,并不是模型「不够聪明」,而是系统工程没做对:循环没有终止条件,状态只存在聊天记录里,工具没有幂等键,失败没有 trace,权限只靠一句「不要做危险操作」。这些问题与模型能力正交,换一个更强的模型也不会自动消失。
这一章先把最小的骨架立起来。后面每一章都会在这个骨架上加一层:第 2 章把「动作」变成协议,第 3、4 章把「执行」关进沙箱,第 5、6 章处理「状态」的投影与记忆,第 7、8 章讨论策略与控制结构,第 9–12 章回答「怎么知道它做对了、怎么在出错时复原」。骨架错了,后面每一层都会歪。
动画:脉冲沿环前进表示一次 step;中心是 STATE 与 BUDGET。开启「减少动态效果」时只显示静态帧;页面不可见时动画暂停。
读图:横向是「一步」,弧线是「多步」。把 agent 与单次调用区分开的不是箭头数量,而是弧线闭合之后仍然存在的两样东西——状态与预算。状态让下一步知道上一步发生了什么,预算让循环知道何时必须停。没有这两样,所谓 agent 只是一个会重复调用 API 的脚本。
1.1 一次调用不是 agent:三个判据
先看三个真实系统,判断它们是不是 agent。
系统 A:客服问答。 用户提问,系统检索知识库,拼接提示词,调用一次模型,返回答案。没有工具调用,没有第二轮,没有跨请求状态。这是单轮 RAG,不是 agent。
系统 B:退款审批工作流。 用户提交退款,系统按固定顺序执行:查订单 → 查风控 → 若金额小于阈值自动通过,否则转人工。步骤是代码写死的,模型只在「生成给用户的解释」这一步被调用。这是工作流(workflow),不是 agent。它的控制流由人预先编排,模型不决定下一步做什么。
系统 C:代码修复助手。 它读 issue,自己决定先看哪个文件;调用 grep 找到相关函数;修改;跑测试;测试失败后读错误、再改;最多 20 步或 10 分钟。它有一个闭环、有工具、有跨步状态,还有预算。这是 agent。
三者都可能用同一个模型、同一套 API,差别不在模型,而在谁决定下一步、动作是否改变外部世界、状态是否跨步累积。
一个 agent 是一个六元组
其中:
- 是状态空间, 是第 步开始时的完整状态;
- 是观察空间, 是执行动作后从环境得到的结果;
- 是动作空间, 是策略产出的动作意图(工具名 + 参数,或终止);
- 是策略,给出动作分布;本书不假设它一定由 LLM 实现;
- 是执行器,负责校验、授权、执行副作用并返回观察;
- 是有限预算向量,见 定义 1.5.1。
一次 step 是
当 包含至少一个会改变 agent 之外世界的动作,且 依赖 时,系统才具备「闭环」;否则它只是把一次函数调用包装成了循环。
这个定义故意把 与 分开。很多实现把「模型输出」直接当成「已经执行」,于是把工具调用、参数校验、权限检查全塞进 prompt。这在 demo 里能跑,在生产里会出现三类问题:模型声称完成了但实际没执行;参数越权但没人拦;执行失败被模型当成成功继续往下走。把 与 分开,就是强迫宿主拥有一个可检查的执行层。
从初始状态 到终止状态 的序列
称为一条轨迹(trajectory)。轨迹的每一步至少应记录:动作、参数、执行结果、耗时、预算余额、状态快照的摘要。没有轨迹,只有最终答案,就无法回答「它为什么这么做」,也无法回放与评估。
判据可以压成一张表。注意「有状态」不是「有聊天记录」:聊天记录只是会话状态的一部分,而且经常是最不可靠的一部分。
| 判据 | 问题 | 不满足时的典型症状 | 本书落点 |
|---|---|---|---|
| 闭环 | 下一步是否依赖上一步的观察? | 固定流水线;失败不会改变后续动作 | 1.2、7.3 |
| 工具 | 动作是否改变外部世界或获取新信息? | 模型只能「说」,不能「做」;或做了但没有回传 | 1.4、2.4 |
| 状态 | 跨步是否累积了可检查的事实? | 只靠聊天记录;压缩后丢失约束;重启即失忆 | 1.3、5.6、6.1 |
| 预算 | 是否有有限且可执行的停止条件? | 无限循环;跑了一夜;账单失控 | 1.5、8.5 |
这张表说明什么:四个判据里,只有「工具」是模型侧能力,其余三个都是宿主侧工程。很多团队把资源全投在换模型上,却在这三处留空,结果 agent 的能力上去了,事故率也上去了。
1.1.1 三个边界案例
判据在极端案例上最容易看清。下面三个系统都「像」agent,但按定义只有一个真正跨过边界。
| 系统 | 闭环 | 工具 | 状态 | 预算 | 结论 |
|---|---|---|---|---|---|
| 研究助手:搜索 → 读网页 → 再搜索 → 写报告 | 是 | 是(只读) | 是 | 是 | agent;但副作用面小,风险主要在数据外泄与引用错误 |
| 代码评审机器人:收到 PR → 一次模型调用 → 贴评论 | 否 | 是(写评论) | 否 | 否 | 不是 agent;是一次带副作用的分类调用 |
| 固定 ETL:抽取 → 模型清洗 → 入库 | 否 | 是(写库) | 否 | 是 | 不是 agent;是工作流,模型只是其中一个算子 |
第一行值得单独说:只读工具也是工具。研究助手不会改文件,但它会决定「搜什么、读哪一页、要不要再搜」,下一步依赖上一步的网页内容。它的风险不是破坏系统,而是把不可信网页里的指令当成任务(第 11.2 节),或者把敏感查询发给外部搜索(第 11.3 节)。「没有写权限」不等于「没有风险」。
第二行暴露了另一个常见混淆:有工具调用不等于有闭环。评审机器人调用了一次 post_comment,但它不会根据评论是否成功、CI 是否通过来决定下一步;它甚至不知道评论发出去之后发生了什么。它的控制流是线性的,工具只是末端的副作用。
第三行则说明「模型参与」不等于「agent 控制」。ETL 的下一步由代码决定,模型只负责清洗。把它叫 agent 会导致错误的评估口径:你会去评估「模型选工具的能力」,而真正该评估的是清洗质量与管道可靠性。
函数调用只证明模型能产出一个符合 schema 的 JSON。它不证明这个 JSON 该被执行、执行结果可信、执行失败了会怎样。就像一个人会填转账单,不等于银行应该无条件转账:还需要账户、余额、授权、回执与对账。agent 的工程复杂度,主要在这后半段。
- 「多轮对话就是 agent」——多轮只说明消息在累积;如果每轮都不依赖工具结果、也不改变外部世界,它仍是聊天。判据是闭环与工具,不是轮数。
- 「agent 一定要用 LLM」——定义里的 可以是规则、搜索、强化学习策略或它们的组合。LLM 只是目前最通用的先验;把 agent 等同于 LLM 会让你忽略控制器本身的设计。
- 「工具越多,能力越强」——工具越多,选择空间越大,误用与注入面也越大。工具数量与可靠性通常呈倒 U 形;第 2 章会讲如何用命名空间、分层与按需发现来控制。
- 「状态就是 messages 数组」——messages 是会话状态的一种表示,且是有损的。任务约束、世界事实、预算余额必须有独立的结构化存放点,否则一次摘要就可能把它们吃掉。
- 「有终止条件就不会死循环」——终止条件必须由宿主执行且每步可检查。写在 prompt 里的「最多 10 步」不是终止条件,模型可以无视它。
1.2 循环:观察、决策、行动、更新
最小循环只有四件事,加上一个判定就是五阶段:
┌──────────────────────────────────────────────────────────┐
│ 宿主(runtime) │
│ │
┌────▼─────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ ① 观察 │───▶│ ② 决策 │───▶│ ③ 行动 │───▶│ ④ 更新 │ │
│ observe │ │ decide │ │ act │ │ update │ │
└──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘ │
▲ │ │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌──────────┐ │
└────────────────────────│ 工具/环境 │ │ 状态+预算 │ │
└────────────┘ └────┬─────┘ │
│ │
┌────▼─────┐ │
│ ⑤ 终止? │ │
└──────────┘ │
└──────────────────────────────────────────────────────────┘
终止 = 目标满足 ∨ 预算耗尽 ∨ 不可恢复错误 ∨ 人工中止
读图:四个阶段里,只有「决策」可能由模型完成;观察、行动、更新、终止都必须由宿主实现。把这条分清,能避免一种常见的架构错误——让模型在自然语言里「模拟」工具执行与状态更新,然后相信它写的总结。
1.2.1 一次 step 的精确语义
把循环写成状态转移,能暴露很多模糊实现里的问题:
输入:初始状态 s0,预算 B
t ← 0
while not done(s_t) and budget_left(B, t):
a_t ← policy(s_t) # 可能失败、可能返回非法动作
if a_t is terminate: break
(s_{t+1}, o_t) ← execute(s_t, a_t) # 校验 → 授权 → 执行 → 观察
s_{t+1} ← update(s_{t+1}, o_t) # 把观察并入状态,扣预算
t ← t + 1
return finalize(s_t, reason)
三个细节决定了实现质量:
policy的失败不是异常,是状态。 模型超时、返回非法 JSON、调用不存在的工具,都应该变成一条观察(例如{"ok": false, "error": "EINVAL"}),让下一步有机会纠正;直接把异常抛穿循环,等于放弃自愈。execute必须原子地返回「新状态 + 观察」。 如果工具已经产生副作用,但状态更新失败,下一轮会基于旧状态重复执行——这就是缺少幂等键时最危险的重复扣款/重复发信。done与budget_left由宿主判定,不由模型声明。 模型可以说「我完成了」,但宿主必须用可检查的条件确认:验收测试通过、目标字段非空、人工批准,或者显式调用task.finish并携带答案。
1.2.2 回合(turn)与步(step)不是一回事
产品文档常把「一轮对话」叫一个 turn。在 agent 里,一个用户回合可能包含几十个 step:读文件、搜索、改代码、跑测试、再改。把 turn 当 step 做预算,会出现两种极端:按对话轮数限流,复杂任务永远做不完;按 token 限流,简单任务被误杀。
| 概念 | 边界 | 典型预算 | 观测粒度 |
|---|---|---|---|
| step | 一次「决策 → 执行 → 更新」 | 步数、工具调用数 | 一条 step span |
| turn | 用户一次输入到最终回复 | 墙钟、总 token、总成本 | 一个 run span |
| session | 跨多个 turn 的会话 | 存储、保留期、记忆容量 | session id |
| task | 一个可验收的目标 | 验收条件、最大重试 | task id + 状态机 |
这张表说明什么:预算要挂在正确的层级上。第 8 章讲多 agent 时,预算还会沿 agent 树传播;如果层级搞混,父子 agent 会各自以为自己还有额度,最后总成本翻倍。
1.2.3 终止:三种正确理由与三种错误理由
正确的终止理由只有三类:目标满足(验收通过)、预算耗尽(步数/时间/成本)、不可继续(不可恢复错误、权限拒绝、人工中止)。错误的终止理由很常见:
- 模型说「完成了」但没有任何可检查的证据;
- 模型陷入了重复动作,宿主误把「连续两次相同动作」当成「稳定」;
- 发生异常,外层
except直接返回了空答案。
设预算为 ,且每个 step 至少消耗 中的 1 个单位、每次工具调用至少消耗 中的 1 个单位。若执行器在预算不足时拒绝继续,则任意轨迹的长度满足
进一步,若墙钟与 token 也在每步单调消耗,则 还受这两者的上界约束。
每个 step 开始时 spend_step 使已用步数加 1,最多执行 次;第 次申请会失败并触发终止。工具调用同理:最多 次成功调用,之后要么终止,要么继续执行不消耗工具的步骤;若循环的每个 step 都必然调用工具,则 。取两者较小值即得。墙钟与 token 的约束是同一论证的连续版本:每步的消耗非负,累计到上界即停。∎
注意定理的前提:每个 step 必须真的消耗预算。如果实现里存在「空转 step」——不调用工具、不增加计数、只把同一条消息再喂给模型——上界就不成立。第 1.6 节的真实事故,正是空转 step 没有被计入预算。
把终止条件写在 prompt 里,等价于把刹车交给乘客;把终止条件写进执行器,才是司机踩刹车。工程上最稳的做法是:预算检查放在 spend_* 函数里,任何路径想继续都必须先申请额度,申请不到就抛出一个受控的终止信号,由最外层统一收尾。
while True+ 模型自己说停——模型没有执行权。必须有宿主侧的done()与预算检查。- 把异常当成终止——异常是「不可继续」的一种,但必须记录原因与最后状态;直接
return ""会让上层误以为任务成功。 - 只按 token 限流——工具调用可能不消耗 token(本地文件操作),但会消耗时间与外部配额;预算必须是向量,不是标量。
1.2.4 事件驱动与时间驱动
上面的循环是「拉」模型:每一步主动调用策略。生产系统里还有两种变体,终止与状态语义不同,混用会出 bug。
事件驱动。 循环阻塞在一个事件队列上,事件可能来自工具完成、用户插话、定时器、外部 webhook。每个事件唤醒一次决策。好处是长任务不必空转轮询;坏处是「一次 step」不再由代码顺序定义,而是由事件批次定义。如果两个事件同时到达,状态更新必须串行化,否则会出现「基于旧状态做决策」的竞态。
时间驱动。 每隔固定时间 tick 一次,适合监控、巡检类 agent。它的预算必须包含「tick 次数」,否则一个 5 秒的 tick 跑一夜就是 17280 次决策。时间驱动还要处理「上一 tick 还没结束」的重叠:默认应跳过重叠 tick,而不是并发堆积。
| 模式 | 何时用 | 预算主分量 | 典型故障 |
|---|---|---|---|
| 拉取式 | 任务短、步骤确定 | 步数 | 空转 step 不计费 |
| 事件驱动 | 长任务、外部回调多 | 事件数 + 墙钟 | 事件乱序、重复投递 |
| 时间驱动 | 监控、周期巡检 | tick 数 + 墙钟 | tick 重叠、状态漂移 |
这张表说明什么:预算的主分量要跟驱动模式匹配。用步数去限时间驱动,会在一次慢 tick 里失控;用墙钟去限拉取式,会在模型快速返回时过早终止。
1.2.5 错误分类:哪些错可以继续,哪些必须停
把「失败」当成一个布尔值,是循环实现里最常见的偷懒。失败至少分四类,处理方式完全不同:
| 类别 | 例子 | 是否可重试 | 循环应做什么 |
|---|---|---|---|
| 瞬时错误 | 网络超时、429、数据库死锁 | 是,带退避 | 重试同一动作,计入重试预算 |
| 参数错误 | schema 不合法、枚举越界、文件不存在 | 否,需改参数 | 把错误作为观察,让策略纠正 |
| 权限错误 | EPERM、scope 不足、审批拒绝 | 否,需升级 | 终止或转人工;不得循环重试 |
| 系统错误 | 磁盘满、进程被杀、状态损坏 | 视情况 | 终止并保留现场;恢复后从检查点重来 |
这四类错误如果都走同一条 except,会出现两种极端:把权限错误当瞬时错误重试,触发告警风暴;把参数错误直接终止,放弃了模型自愈的机会。正确做法是在工具适配器里把底层异常翻译成这四类,并给出 retryable 与 suggested_action 字段(第 1.4.2 节)。
1.3 状态:世界、任务、会话、控制
状态是 agent 最容易偷懒的地方:把一切都塞进 messages,需要时再让模型「回忆」。这在短任务里可行,在长任务里必然出问题,因为消息会被截断、摘要、重排,而任务约束与预算余额不能被有损压缩。
一个可运维的 agent 状态至少分为四层:
| 层 | 内容 | 谁写 | 是否可有损 | 生命周期 |
|---|---|---|---|---|
| 世界状态 | 文件、数据库、外部系统、工单的真实状态 | 工具执行后由宿主回写 | 否 | 跨 step,可能跨 session |
| 任务状态 | 目标、约束、验收条件、子目标、完成度 | 规划器/宿主 | 否 | 一个 task |
| 会话状态 | 消息、工具结果、用户偏好 | 循环 | 是(可压缩) | 一个 session |
| 控制状态 | 步数、预算余额、重试计数、终止原因 | 执行器 | 否 | 一个 run |
四层合起来是 。任何一层缺失,都会导致一类特定故障:缺世界状态会重复执行;缺任务状态会目标漂移;缺会话状态会失忆;缺控制状态会停不下来。
这四层不是学术分类,而是四种不同的持久化需求。世界状态应该以外部系统为准,agent 只保存引用与版本号;任务状态应该可序列化、可人工编辑;会话状态可以压缩;控制状态必须精确、不可被模型改写。
┌─────────────────────────── run(一次执行) ───────────────────────────┐
│ control: steps=7 tools=4 tokens=18.2k deadline=12:40:30 stop=null │
│ │
│ ┌──────────────────── task(一个可验收目标) ─────────────────────┐ │
│ │ goal: "修复 issue #482 的回归" │ │
│ │ constraints: ["不得改 public API", "必须补测试"] │ │
│ │ subgoals: [reproduce ✓, locate ✓, patch ✓, test ✗, review ○] │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── session(消息与观察) ─────────────────────┐ │
│ │ msg[0..12] tool_result[0..3] summary(旧消息) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
│ 引用 / 版本号
▼
┌──────────────────── world(外部事实,agent 不拥有) ────────────────────┐
│ git HEAD=9f2c1a worktree=/tmp/agent-7f issue.state=open ci=red │
└────────────────────────────────────────────────────────────────────────┘
读图:从下往上看,世界状态是唯一「agent 不拥有」的层。它只能通过工具读取或修改,并且必须带版本(git SHA、etag、行号)。中间三层是 agent 自己的记账;最上面的控制状态是唯一允许终止循环的层。把这四层混在一个 messages 数组里,等于把账本、合同和聊天记录订在一起——查账时谁也说不清哪一页算数。
1.3.1 状态快照与摘要
每步都存完整状态不现实:一个 200 KB 的工具结果乘 50 步就是 10 MB。实践中的折中是快照 + 增量:
- 快照只存可比较的关键字段:任务完成度、预算余额、世界版本号、当前子目标;
- 增量存本步的动作、参数、结果摘要与错误码;
- 大对象(文件内容、网页)存引用(路径 + 内容哈希 + 行号范围),需要时再取。
{
"step": 3,
"task": {"goal": "sum", "done": false, "subgoal": "write"},
"world": {"root": "/tmp/mini-loop-7f", "files": ["numbers.txt", "sum.txt"]},
"control": {"steps": 3, "tool_calls": 3, "stop_reason": null},
"action": {"tool": "fs.write", "args": {"path": "sum.txt", "content": "23"}},
"observation": {"ok": true, "bytes": 2},
"refs": [{"kind": "file", "path": "numbers.txt", "sha256": "…"}]
}
这段 JSON 是 experiments/ch01/mini_loop.py 的 trace 记录(省略了部分字段)。关键点是 task 与 control 不依赖消息文本:即使把 messages 全部压缩成一句摘要,done 与 steps 仍然准确。
某代码 agent 在第 12 步把前 10 条消息压缩成 200 字摘要,摘要写「用户要求优化性能」。但原始约束里有一条「不得改变 API 兼容性」。第 13 步模型为了「优化」删除了一个公开参数,测试通过,兼容性检查失败。
问题不在摘要模型,而在约束被放进了会话状态。修复:把 constraints 提升到任务状态,作为不可压缩字段;每次调用模型时单独注入;摘要只压缩历史对话,不压缩约束。修复后同一任务在第 18 步通过,兼容性检查保持绿色。
这个例题说明:状态分层不是为了「架构好看」,而是为了让不同的压缩策略作用于不同的层。会话状态可以有损,任务状态不能;世界状态甚至不该被 agent 复制,只该被引用。
1.3.2 状态哈希与幂等
当 agent 可能重试、崩溃恢复或多副本运行时,必须回答一个问题:这一步是否已经执行过? 常见做法是给「动作 + 关键状态」算一个指纹:
import hashlib, json
def action_fingerprint(tool: str, args: dict, world_version: str) -> str:
payload = json.dumps(
{"tool": tool, "args": args, "world": world_version},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
指纹相同不代表可以跳过:读操作幂等,写操作未必。正确做法是让工具自己声明幂等性,并由执行器维护一个 (fingerprint → result) 的结果表:
- 幂等工具:命中结果表直接返回旧结果,不重复执行;
- 非幂等工具:命中结果表说明「可能已经执行过」,必须查询外部系统确认,或要求调用方提供幂等键;
- 不可逆工具:默认拒绝自动重试,转人工确认(第 12 章)。
在写第一行循环之前,先回答:
- 世界状态以谁为准? 数据库、git、工单系统,还是 agent 的内存?给出读取方式与版本字段。
- 哪些字段不可压缩? 目标、硬约束、验收条件、预算余额、权限范围。把它们列成 schema。
- 崩溃后如何恢复? 重放 trace、从检查点恢复,还是重新开始?恢复时如何避免重复副作用?
- 哪些状态可以给模型看? 世界状态里可能有 PII、密钥、他人数据;注入前必须脱敏(第 10.4 节)。
- 状态如何淘汰? TTL、容量上限、删除请求如何传播到记忆与缓存(第 6.6 节)?
五问里只要有任意一问没有答案,先别急着接模型;补完再写循环,返工率会低很多。
状态不是越全越好。每多存一个字段,就多一处脱敏、迁移、序列化与一致性负担。一个实用的边界是:只存「重新计算很贵」或「无法重新计算」的东西。文件内容可以重读,不必存;用户刚说过的偏好无法重算,必须存;工具返回的临时 ID 可以重取,但「这个 ID 属于哪个任务」必须存。
1.4 工具:从函数到动作接口
很多人把工具理解成「一个能被模型调用的函数」。这个理解漏掉了工程上最重要的部分:工具是动作接口,它必须说明自己能做什么、在什么前提下能做、做完之后世界变成什么样、失败能否重试、代价是多少、需要什么权限。
一个工具契约至少包含八个字段:
| 字段 | 作用 | 缺失时的故障 |
|---|---|---|
name |
稳定标识,带命名空间 | 重名覆盖;模型调用到错误工具 |
description |
何时用、何时不用 | 模型凭名字猜用途,误用率高 |
input_schema |
参数类型、必填、枚举、边界 | 非法参数进入执行器;注入面扩大 |
preconditions |
执行前必须成立的条件 | 对不存在/无权限的对象执行 |
effect |
读 / 写 / 不可逆 / 外部可见 | 把不可逆操作当普通写重试 |
idempotency |
是否幂等;幂等键怎么给 | 重试导致重复扣款、重复发信 |
cost |
金额、配额、延迟量级 | 预算失真;尾延迟失控 |
auth_scope |
需要哪个 capability / scope | 越权;confused deputy(第 2.8 节) |
input_schema 只约束语法;preconditions 与 auth_scope 约束语义与权限。三者缺一不可。
以「给订单退款」为例,一个合格的契约不是 refund(order_id, amount),而是:
{
"name": "billing.refund",
"description": "对已支付的订单发起全额或部分退款。仅在用户明确要求退款、且订单状态为 paid 时使用。不要用于取消未支付订单。",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": "^ord_[A-Za-z0-9]{12}$"},
"amount_cents": {"type": "integer", "minimum": 1, "maximum": 500000},
"reason": {"type": "string", "enum": ["customer_request", "duplicate", "fraud"]},
"idempotency_key": {"type": "string", "minLength": 16, "maxLength": 64}
},
"required": ["order_id", "amount_cents", "reason", "idempotency_key"],
"additionalProperties": false
},
"preconditions": ["order.status == 'paid'", "amount_cents <= order.refundable_cents"],
"effect": "irreversible_external",
"idempotency": "required_key",
"cost": {"money_cents": 0, "latency_p95_ms": 1800, "quota": "billing:100/hour"},
"auth_scope": "billing:refund"
}
这段 JSON 里,真正防止事故的不是 input_schema,而是后面四行:preconditions 让执行器在退款前查订单状态;effect 告诉重试逻辑「这是不可逆的外部动作」;idempotency 要求调用方给出幂等键;auth_scope 把权限收缩到单一动作。模型只能看到前三个字段(名称、描述、schema),后五个字段是宿主与工具之间的契约,不应该全部暴露给模型——暴露权限范围反而会诱导模型去试探边界。
1.4.1 工具与 API 的三个差别
| 维度 | 普通 API | agent 工具 |
|---|---|---|
| 调用者 | 知道业务语义的代码 | 只知道自然语言与 schema 的模型 |
| 参数来源 | 上游代码构造 | 可能来自网页、文件、另一个工具的输出(不可信) |
| 失败处理 | 调用方写 try/except | 失败要变成观察,供模型纠正;且不能重复副作用 |
| 权限 | 进程凭证 | 按调用者、任务、数据范围动态收敛 |
| 可观测性 | 日志 | 每次调用是一个 span,带参数摘要、结果、成本 |
这张表说明什么:把一个内部 API 直接暴露成工具,通常会同时放大三类风险——模型误用、参数注入、权限过宽。工具层需要一层「适配器」,做参数归一化、权限检查、结果裁剪与错误翻译,而不是把 REST 端点原样贴给模型。
1.4.2 结果封装:让失败也能被决策
工具返回什么,决定了模型下一步能做什么。只返回成功数据,失败就只能靠异常;返回整个 HTTP 响应,模型会被无关字段淹没;返回自然语言,宿主又无法稳定处理。一个够用的结果封装至少包含六个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
ok | boolean | 是否成功;失败时其余字段仍要可读 |
data | object | 成功时的结构化结果,按工具契约裁剪 |
error | object | code、message、retryable、suggested_action |
refs | array | 结果依赖的外部对象引用(路径、ID、版本号) |
cost | object | 本次调用的 token、金额、延迟 |
observed_at | string | ISO 8601 时间;用于判断结果是否过期 |
错误对象里的 suggested_action 是给策略看的,不是给用户看的。例如 EINVAL 的 suggested_action 可以是「检查必填字段并重试一次」,EPERM 的则是「停止并请求人工授权」。把两者都写成「出错了,请重试」,等于让模型在权限问题上撞墙。
{
"ok": false,
"error": {
"code": "EPERM",
"message": "refund amount 90000 exceeds refundable 20000",
"retryable": false,
"suggested_action": "stop_and_request_human"
},
"refs": [{"kind": "order", "id": "ord_7Q2K9LmN4pXr", "version": 12}],
"cost": {"latency_ms": 42, "money_cents": 0},
"observed_at": "2026-10-19T01:22:31Z"
}
这段结果的关键不是 ok=false,而是 retryable=false 与 suggested_action。没有这两个字段,策略只能看到「失败」,然后凭运气选择重试或放弃;有了它们,循环可以把「不可重试」直接升级成终止或人工审批,而不是烧完预算才发现无解。
- 「描述写清楚就够了」——描述影响模型选择,但不构成约束。真正的约束在 schema、前置条件与执行器。
- 「参数校验是模型的事」——模型可能被注入,参数可能来自不可信网页。校验必须在宿主侧再做一次,且以宿主为准。
- 「工具失败就把异常抛给模型」——抛栈信息既不可读也可能泄密。应翻译成结构化错误:错误码、可重试性、建议动作。
- 「幂等是数据库的事」——agent 的重试发生在工具层;没有幂等键,数据库事务也救不了重复调用。
- 「工具越原子越好」——原子工具组合灵活,但步数与 token 成本高;粗粒度工具便宜,但难以授权与回滚。粒度要按「权限边界 + 可逆性」切,不是越细越好。
按钮只告诉你「按下去会发生什么」,动词还告诉你「谁可以按、在什么条件下按、按错了怎么撤销」。agent 的工具设计应该像设计一组动词:read_file、apply_patch、run_tests、request_approval。每个动词的语义边界清楚,组合起来才可控。
1.5 控制:预算、终止与死循环
预算不是「防止账单爆炸」的保险丝,而是控制器的一部分:它决定策略在多深的搜索里还能保持理性。一个没有预算概念的 agent,在遇到不确定时会倾向于「再试一次」,而每一次尝试都可能带来新的副作用。
预算是一个五元组
每个分量都是有限的非负数,并在每步单调消耗。执行器在每次动作前检查对应分量;任一分量耗尽即触发终止,终止原因写入控制状态。预算可以按层级分配(run / turn / task / agent),子预算之和不得超过父预算。
为什么是向量而不是标量?因为不同资源的稀缺性不同:本地文件读取消耗步数与时间,不消耗 token;长上下文推理消耗 token 与金钱,不消耗工具配额;外部 API 调用消耗配额与延迟。只盯 token,会漏掉工具配额与墙钟;只盯步数,会漏掉一次调用 10 万 token 的推理。
1.5.1 停止条件的三层实现
一个可靠的停止条件要同时在三层实现,缺一层就会出现「以为停了其实没停」:
| 层 | 实现 | 作用 |
|---|---|---|
| 策略层 | 提示词/策略输出 finish 意图 | 正常情况下体面结束 |
| 执行器层 | spend_step / spend_tool / deadline 检查 | 模型不配合时强制结束 |
| 进程层 | 墙钟超时、进程组 kill、cgroup 限额 | 执行器本身卡死时的最后一道闸 |
第三层最容易被忽略。如果工具是一个会阻塞的子进程,执行器可能在 read() 上永久等待,前两层根本没有机会运行。第 3 章会讲如何用进程组、pidfd 与 SIGKILL 做真正的墙钟超时;这里先记住:预算检查必须发生在不会阻塞的地方。
1.5.2 死循环检测:动作指纹与进展度量
重复动作有两种:一种是合理的重试(网络抖动),一种是病态的空转(观察没变,模型还在做同一件事)。区分它们需要「进展」的概念。
设存在一个良基的进展度量 ,且对任意「有进展」的 step 有 。若执行器保证:
- 每个 step 消耗至少一个预算单位;
- 连续 步的动作指纹与观察指纹完全相同则强制终止;
- 预算有限;
则任意轨迹有限,且终止原因可归入「目标满足 / 预算耗尽 / 无进展 / 不可继续」之一。
由条件 1 与有限预算,步数有上界(定理 1.2.1)。若不触发预算终止,则轨迹无限,说明 只能有限次严格下降;此后所有 step 都不改变 。若这些 step 的动作与观察指纹最终稳定,条件 2 在至多 步后触发;若指纹持续变化但 不降,则动作在状态空间里游走,仍受预算上界约束。因此不存在无限轨迹。终止原因由执行器在触发点写入,四类互斥。∎
∎这个定理的工程价值在于把「防死循环」拆成三个可实现的检查:预算、指纹、进展度量。三者不需要都完美,但至少要有一个能在有限步内触发。
一个够用的指纹实现:
import hashlib, json
def fingerprint(action: dict, observation: dict) -> str:
payload = json.dumps(
{"a": action, "o": observation},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:12]
# 在循环里维护最近 k 个指纹;全部相同则判定无进展
window: list[str] = []
...
window.append(fingerprint(action, observation))
window = window[-5:]
if len(window) == 5 and len(set(window)) == 1:
stop_reason = "no_progress"
break
注意指纹包含观察,不只是动作。同一动作在不同观察下可能是合理的(例如轮询一个任务直到完成);只有「动作与观察都不变」才说明系统卡住了。反过来,如果观察在变但任务完成度不涨,指纹检测不会触发,需要进展度量兜底。
1.5.3 预算分配:先给最贵的步骤留余量
预算不是平均分给每一步。agent 的步骤成本分布高度偏斜:读一个文件可能 50 ms,跑一次测试可能 90 秒,调用一次长上下文模型可能 3 美元。平均分配会导致两个问题:前面的廉价步骤花不掉预算,后面的昂贵步骤没额度;或者反过来,前置探索烧光额度,真正关键的验证做不了。
一个可操作的分配顺序是:
- 先扣输出预留。 每次模型调用都要给输出留 token;输入塞满会导致回答截断,比输入超限更难发现。
- 再扣验证预算。 测试、编译、人工审批这些「证明做对了」的步骤,必须优先于更多探索。
- 再扣重试预算。 每个工具单独设重试上限,不要把全局重试次数当成所有工具共享。
- 最后才是探索预算。 搜索、读文件、试错用剩余额度;额度不足时策略应降级为「少探索、多确认」。
B_total
├─ reserve_output = 15% # 每次调用的输出空间
├─ reserve_verify = 25% # 测试 / 编译 / 审批
├─ reserve_retry = 10% # 按工具分配,单工具 ≤ 3 次
└─ explore = 50% # 搜索 / 读取 / 试错
├─ step ≤ 60% of explore
└─ token ≤ 100% of explore
这段分配不是行业标准,而是一个可解释的起点。真正的数值要按任务类型调:代码修复的验证成本高,reserve_verify 应该更大;客服问答的探索成本低,可以压缩 explore。关键是每一项都有名字、有上限、能在 trace 里看到消耗,而不是一个神秘的 max_iterations=100。
发布一个 agent 前,逐条确认:
- 预算向量的五个分量都有值,且都能被执行器读取;
- 每个 step 都经过
spend_*,没有绕过预算的「快捷路径」; - 墙钟超时能在工具阻塞时生效(进程组 /
pidfd/ cgroup); - 连续 步「动作 + 观察」指纹相同的检测已接入;
- 有独立的进展度量(完成子目标数、测试通过数、剩余错误数),且不依赖模型自评;
- 终止原因写入 trace,并能区分四类;
- 人工中止是一个正常路径,不是异常。
七条里任何一条缺失,都可能让「最多 10 步」变成「跑了 10 小时」。
- 「预算就是 max_tokens」——token 只是五分之一;工具配额、墙钟、金额、步数都可能先耗尽。
- 「超时用
signal.alarm就够」——alarm在阻塞系统调用上可能被重启(SA_RESTART),且只作用于当前线程;子进程不会被自动杀死。要用进程组 +SIGKILL,或用pidfd等待。 - 「重试不花钱」——重试消耗预算、放大尾延迟,还可能重复副作用。重试必须有上限、退避与幂等键。
- 「预算可以事后补」——补预算等于允许无限循环;如果确实需要延长,应该由人批准一个新的 run,并保留旧 run 的终止记录。
1.6 失败案例:一个停不下来的 agent
这是本书第一个真实复现的失败案例。为了可复现,它被压缩成一个确定性脚本;事故的形状与线上一致。
1.6.1 事故现场
任务:读 numbers.txt,求和,写入 sum.txt。一个看似正确的循环,跑出了下面的 trace:
python3 experiments/ch01/mini_loop.py --mode loop --max-steps 4 --trace /tmp/t.jsonl
{
"answer": null,
"mode": "loop",
"steps": 4,
"stop_reason": "max_steps",
"trace_steps": 4
}
打开 trace,看到的是四次一模一样的动作:
jq -r '.decision.tool' /tmp/t.jsonl | sort | uniq -c
4 fs.read
每一步的观察也都一样:
jq -r '[.step, .result.ok, (.state.control.steps|tostring), (.budget_before.steps_left|tostring)] | @tsv' /tmp/t.jsonl
1 true 1 3
2 true 2 2
3 true 3 1
4 true 4 0
1.6.2 根因不是「模型笨」
脚本里的策略被故意写成永远返回 fs.read,模拟模型的一种真实行为:观察没有提供新信息,但策略仍然选择同一个动作。模型侧的原因是它没有「进展」的概念;宿主侧的原因是循环只检查了「是否完成」,没有检查「是否有进展」。
第一层:预算。 max_steps=4 让循环在 4 步后停止,避免无限运行。这是必要的,但不是充分的:线上更常见的是 200 步上限,模型在第 3 步就卡住,却白烧了 197 步。
第二层:指纹。 连续 3 步「动作 + 观察」相同即终止。修复后,同一任务在第 3 步以 no_progress 结束,成本从 200 步降到 3 步。
第三层:进展度量。 任务状态里维护 subgoals_done。如果连续 步没有新增完成的子目标,强制重规划或转人工。这一层能捕获「动作在变、观察在变,但目标没有推进」的游走。
三层的关系是:预算是硬上界,指纹是局部检测,进展度量是全局检测。只做第一层,成本高;只做第二层,可能误杀合理的轮询;只做第三层,反应慢。三层都做,才能既省钱又不误杀。
1.6.3 修复后的行为
在 mini_loop.py 的 loop 模式里加入指纹窗口后,max_steps=20 的运行会在第 4 步停止:
step 1 read ok=true fp=8f3a1c
step 2 read ok=true fp=8f3a1c (repeat 1)
step 3 read ok=true fp=8f3a1c (repeat 2)
step 4 read ok=true fp=8f3a1c (repeat 3) → stop_reason=no_progress
这不是「让模型更聪明」,而是「让宿主更早发现没有进展」。在真实系统里,同样的逻辑可以把一次 40 分钟、$12 的失败运行压到 40 秒、$0.2。
1.6.4 修复的核心代码
修复不需要改策略,只需要在循环里加一个窗口和一个判定。下面是可移植的版本,直接插在 call_tool 之后、写 trace 之前:
import hashlib, json
def step_fingerprint(action: dict, observation: dict, world_version: str) -> str:
payload = json.dumps(
{"action": action, "observation": observation, "world": world_version},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:12]
WINDOW = 3
recent: list[str] = []
# 循环内,执行完动作之后
fp = step_fingerprint(action, result, state.world.version())
recent.append(fp)
recent = recent[-WINDOW:]
if len(recent) == WINDOW and len(set(recent)) == 1:
state.control.stop_reason = "no_progress"
break
三个细节决定了它是否可靠:
- 世界版本参与指纹。 如果两次读文件之间文件被外部改了,动作与结果可能相同,但世界版本变了;这不算无进展,应该允许继续。
- 窗口长度要大于合理重试次数。 如果网络重试允许 3 次,窗口设 3 会把正常重试误判为死循环;应设为「最大合理重试 + 1」。
- 判定放在执行之后、下一步之前。 这样最后一次重复动作的副作用已经发生,但不会再有第四次;如果要求更严格,可以在执行前用「动作 + 当前观察」预判,命中则直接拒绝执行。
第一层代价是浪费的 token 与工具调用;第二层代价是副作用——重复发信、重复退款、重复改文件。指纹检测越晚,第二层代价越大。所以检测应该放在执行前:如果这一步与上一步的动作、观察、世界版本都相同,执行器可以直接拒绝,而不是先执行再判断。
1.7 最小内核:可运行的 120 行
experiments/ch01/mini_loop.py 把前面的概念压成一个可运行的内核:四层状态、三个工具、确定性策略、预算、trace。它不是生产框架,而是一把尺子:后面每章增加的能力,都可以问「它加在这个内核的哪一层」。
1.7.1 状态与预算
@dataclass
class Control:
max_steps: int = 8
max_tool_calls: int = 6
steps: int = 0
tool_calls: int = 0
stop_reason: str | None = None
def spend_step(self) -> bool:
if self.steps >= self.max_steps:
self.stop_reason = "max_steps"
return False
self.steps += 1
return True
def spend_tool(self) -> bool:
if self.tool_calls >= self.max_tool_calls:
self.stop_reason = "max_tool_calls"
return False
self.tool_calls += 1
return True
关键行是 if self.steps >= self.max_steps 在自增之前:它保证第 max_steps+1 次申请失败,而不是先执行再发现超限。spend_tool 同理。两个函数都把原因写进 stop_reason,这样最外层不需要猜。
1.7.2 工具契约与执行
def call_tool(state: State, name: str, args: dict[str, Any]) -> dict[str, Any]:
spec = TOOLS.get(name)
if spec is None:
return {"ok": False, "error": "ENOTOOL", "tool": name}
if err := validate(spec, args):
return {"ok": False, "error": "EINVAL", "detail": err}
try:
return spec.fn(state, args)
except ValueError as exc:
return {"ok": False, "error": "EINVAL", "detail": str(exc)}
这里有三层防御:未知工具、schema 校验失败、执行期参数错误。三者都返回结构化错误,而不是抛异常。返回的 dict 直接进入会话状态,下一步策略可以读到 error 并纠正。
1.7.3 循环与 trace
while not state.task.done:
if not state.control.spend_step():
break
action = policy(state, mode)
step = {"step": state.control.steps, "decision": action, ...}
if not state.control.spend_tool():
step["result"] = {"ok": False, "error": "EBUDGET"}
else:
step["result"] = call_tool(state, action["tool"], action.get("args", {}))
state.session.messages.append(
{"role": "tool", "name": action["tool"], "result": step["result"]}
)
step["state"] = state.snapshot()
trace.append(step)
这段循环没有花哨的并发,但它满足了前面所有判据:闭环(策略读 messages)、工具(call_tool 改变 files)、状态(四层快照)、预算(两个 spend_*)。task.finish 把 task.done 置真,循环在下一轮条件判断时自然退出。
1.7.4 真实输出
正常路径:
python3 experiments/ch01/mini_loop.py --task sum --mode good
{
"answer": "sum=23",
"files": ["numbers.txt", "sum.txt"],
"mode": "good",
"steps": 3,
"stop_reason": "done",
"sum_txt": "23",
"task": "sum",
"trace_steps": 3
}
三步分别是:读文件、写结果、提交答案。最后一步的 trace 里可以看到 task.done=true 与预算余额:
{
"budget_before": {"steps_left": 5, "tools_left": 4},
"decision": {"args": {"answer": "sum=23"}, "tool": "task.finish"},
"result": {"answer": "sum=23", "ok": true},
"state": {
"control": {"max_steps": 8, "max_tool_calls": 6, "steps": 3,
"stop_reason": null, "tool_calls": 3},
"files": ["numbers.txt", "sum.txt"],
"task": {"answer": "sum=23", "done": true, "goal": "读取 numbers.txt,求和并写入 sum.txt"}
},
"step": 3
}
测试覆盖四条不变量:正常结束并写文件、预算终止、schema 拒绝额外字段、trace 可序列化为 JSONL。
python3 -m unittest discover -s experiments/ch01 -v
test_badarg_is_rejected_by_schema ... ok
test_good_run_finishes_and_writes_file ... ok
test_loop_hits_budget ... ok
test_trace_is_jsonl_serializable ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.003s
OK
本书的多数实验用确定性策略替代 LLM,原因有三个:可复现、零成本、能把失败模式钉死。真模型引入采样方差,同一个 bug 可能十次只复现一次;在讲机制时,方差是噪声。等机制讲清楚,第 9 章再引入真实模型与统计显著性。你可以把 policy() 换成任何模型调用,循环的其余部分不需要改——这正是「策略与执行器分离」的好处。
1.7.5 把策略换成真模型:只改一个函数
最小内核的 policy(state, mode) 是一个普通函数。接真实模型时,不需要改循环、工具、预算与 trace,只需要把它替换成一个适配器。适配器只做三件事:把状态投影成消息、调用模型、把模型输出解析成动作。
def model_policy(state: State) -> dict:
"""把模型输出解析成 {tool, args};解析失败时返回一个可观察的错误动作。"""
messages = build_messages(state) # 第 5 章:预算、压缩、优先级
resp = llm.chat(
messages=messages,
tools=[to_openai_tool(spec) for spec in TOOLS.values()],
tool_choice="auto",
parallel_tool_calls=False,
)
call = first_tool_call(resp) # 第 2.3 节:流式增量与 index
if call is None:
return {"tool": "task.finish", "args": {"answer": resp.text or ""}}
try:
args = json.loads(call.arguments or "{}")
except json.JSONDecodeError as exc:
return {"tool": "__invalid__", "args": {"detail": str(exc)}}
return {"tool": call.name, "args": args}
四个边界必须处理,否则适配器会成为新的故障源:
- 模型返回多个工具调用。 如果宿主按顺序执行,第二个可能基于第一个的旧观察;如果并行执行,副作用可能冲突。默认串行执行,并把「并行」当成显式能力(第 2.3 节)。
- 模型返回不存在的工具。 不要静默丢弃;返回
ENOTOOL观察,让模型看到自己的错误。 - 参数不是合法 JSON。 同上,转成
EINVAL观察,而不是抛异常。 - 模型直接给出最终文本而不调用
finish。 这要看产品语义:如果文本就是答案,可以包装成task.finish;如果任务要求证据,应该拒绝并提示「请调用 finish 并附上验证结果」。
模型说的是「意图语言」,宿主说的是「动作语言」。适配器只负责翻译,不负责判断该不该执行;判断在执行器里。把两者混在一起,就会出现「模型说退款,适配器直接调退款 API」的灾难。翻译层可以宽松,执行层必须严格。
1.8 观测:一条轨迹的解剖
如果只能保留一种可观测性数据,选 trace,而不是日志或指标。日志解释单步的细节,指标回答趋势,trace 把「一次运行」还原成有因果关系的树。第 10 章会展开 trace 的模型与语义约定;这里先看最小版本。
一条 step 记录至少要有六个字段:
| 字段 | 例子 | 用途 |
|---|---|---|
step | 3 | 顺序、重放 |
decision | {"tool":"fs.write","args":{...}} | 模型意图;区分「想做」与「做成」 |
result | {"ok":true,"bytes":2} | 执行结果;失败时含错误码 |
state | 四层快照摘要 | 定位状态漂移 |
budget_before | {"steps_left":5,"tools_left":4} | 解释终止原因 |
duration_ms | 12 | 找慢步骤;生产环境还应带 trace_id/span_id |
生产环境还要加:run_id、task_id、model、prompt_hash、tool_version、attempt、traceparent。没有 prompt_hash,你无法判断两次运行的差异来自提示词还是模型;没有 tool_version,工具升级导致的回归会被误判为模型退化。
1.8.1 三条观测命令
不需要先上平台,jq 就能回答大部分问题:
# 1) 每个工具被调用了多少次
jq -r '.decision.tool' trace.jsonl | sort | uniq -c | sort -nr
# 2) 哪些步骤失败了,错误码是什么
jq -r 'select(.result.ok == false) | [.step, .decision.tool, .result.error] | @tsv' trace.jsonl
# 3) 预算是怎么耗尽的
jq -r '[.step, .budget_before.steps_left, .budget_before.tools_left] | @tsv' trace.jsonl
第一条命令在 1.6 节的事故里给出了「4 次 fs.read」;第二条会给出 EINVAL 或 EBUDGET;第三条能看出预算是被步数还是工具调用先耗尽。三条都不需要额外依赖。
1.8.1 从 trace 到四个基本指标
trace 是原始材料;判断 agent 是否健康,还需要把它聚合成指标。最小的一组只有四个:
四个指标要一起看。成功率上升但平均步数翻倍,可能是模型更谨慎,也可能是它在试错;工具错误率下降但单次成本上升,可能是重试策略从「快速失败」变成了「反复尝试」。只看一个指标,很容易把退化当成改进。
还要按终止原因分层,而不是只算总成功率:
| 终止原因 | 含义 | 健康信号 |
|---|---|---|
done | 目标满足并通过验收 | 应占多数 |
max_steps | 步数耗尽 | 持续上升说明任务变难或策略退化 |
no_progress | 指纹检测触发 | 应少而稳定;突然上升说明工具或提示词坏了 |
error | 不可恢复错误 | 按错误码分桶,权限类应为 0 |
human_abort | 人工中止 | 结合审批记录看,不一定是坏事 |
这张表说明什么:done 的比例不是唯一目标。一个把 max_steps 调到 1000 的系统,成功率可能很高,但每次失败都烧掉 1000 步;一个把 no_progress 窗口设得很短的系统,成功率可能下降,但失败更快、更便宜。指标必须和预算、终止原因一起读。
1.8.2 采样与脱敏:trace 不能什么都记
trace 的默认倾向是「全记」,这在生产里会制造两类风险:存储成本失控,以及把 PII/密钥写进一个访问控制更弱的系统。一个可执行的策略是分层采样 + 字段级脱敏:
| 数据 | 默认策略 | 理由 |
|---|---|---|
| 步骤元数据(工具名、耗时、错误码、预算) | 100% 保留 | 体积小;排障与指标都依赖它 |
| 工具参数 | 记哈希 + 前 64 字符;敏感字段打码 | 参数可能含 token、邮箱、身份证号 |
| 工具结果 | 记摘要 + 引用;全文另存受控存储 | 结果可能是用户隐私或第三方内容 |
| 模型输入/输出 | 按 run 采样(如 5%)+ 失败全采 | 体积最大;失败样本最有价值 |
| 审批与权限决策 | 100% 保留,不可篡改 | 审计证据(第 12.2 节) |
这张表说明什么:采样要按证据价值分配,而不是按体积平均。失败 run 的完整输入输出必须保留,因为下一次回归就靠它;成功 run 可以只留元数据。脱敏要在写入前做,而不是查询时做——查询时的脱敏很容易被一个 SELECT * 绕过。
- 看终止原因:
done/max_steps/no_progress/error。先确定是「做完了」「没做完但停了」还是「崩了」。 - 看最后 3 步:动作、观察、状态快照。多数问题在最后三步已经显形。
- 看重复:按工具名与参数哈希分组,找重复次数最高的动作。
- 看失败:筛
ok=false,按错误码聚合;EINVAL是参数问题,EPERM是权限问题,EBUDGET是预算问题。 - 看时间:按
duration_ms排序,找最慢的 span;如果工具占了 80%,问题不在模型。 - 看状态漂移:比较第 1 步与最后一步的
task.constraints;如果约束消失了,是上下文压缩的问题(第 5 章)。 - 写一条回归用例:把这次的最小复现输入保留下来,加入第 9 章的评估集。
七步做完还定位不到,再去看完整日志;先看 trace 能避免在几万行日志里迷路。
某 agent 的最终答案是「已修复并测试通过」,但 trace 显示:
step 6 run_tests ok=false error=ETIMEDOUT
step 7 task.finish answer="已修复并测试通过"
第 6 步测试超时,第 7 步模型仍然提交了成功答案。根因是 task.finish 没有检查任务状态里的 last_test_ok。修复:在 task.finish 的前置条件里加 last_test_ok == true;不满足时返回 EPRECOND,让模型看到「测试未通过」而不是直接结束。
这类 bug 在只看最终答案时完全不可见;trace 把「声称」与「事实」并排放在一起,才让它暴露。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 《Linux 系统与内核》第 2 篇 | 2.7 进程创建与终止、2.8 程序执行与子进程监控 | 1.2 循环的执行器、3.2 收尸 | 交叉引用 |
| 《Linux 系统与内核》第 2 篇 | 2.10 信号、2.19 作业控制 | 1.5 墙钟超时、3.2 进程组 | 交叉引用 |
| 《深度学习导论》 | 第 2 讲 序列建模与注意力 | 5.2 上下文窗口与位置偏差 | 背景 |
| 《深度学习导论》 | 第 9 讲 大规模并行训练 | 9.6 成本-延迟-质量 | 背景 |
| MCP 2026-07-28 | Architecture、Tools | 1.4 工具契约、2.4 原语 | 背景 |
| OCI Runtime Spec 1.3 | process、config | 3.6 从 rlimit 到容器 | 背景 |
| ReAct(Yao et al., 2022) | 推理与行动交错 | 1.2 循环、7.2 ReAct | 完整 |
| CoALA(Sumers et al., 2023) | 认知架构 | 1.3 四层状态 | 背景 |
读这张表要抓住两点:本书对 OS 与 DL 只做交叉引用,不重复讲 fork、信号、注意力;对 agent 新增层(循环、状态、工具契约)则给到可实现的细节。表中「背景」表示只借用一个概念,正文不会展开母本内容。
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 把「多轮对话」当 agent | 没有工具、没有状态,只有消息累积 | 用闭环/工具/状态三判据判断 | 1.1 |
| 2 | 让模型自己宣布完成 | 最终答案说成功,测试其实失败 | task.finish 加前置条件;宿主验收 | 1.2、1.4 |
| 3 | 预算只有 max_tokens | 工具配额或墙钟先耗尽,或反过来 | 预算向量五分量,每步检查 | 1.5 |
| 4 | 终止条件写在 prompt 里 | 模型无视「最多 10 步」,跑了 200 步 | 宿主侧 spend_* + 进程级超时 | 1.5 |
| 5 | 把状态全塞进 messages | 摘要后约束丢失,目标漂移 | 四层状态;约束进任务状态 | 1.3 |
| 6 | 摘要压缩了硬约束 | 兼容性/安全要求消失 | 约束不可压缩,单独注入 | 1.3、5.4 |
| 7 | 工具没有幂等键 | 重试导致重复退款/发信 | 契约声明幂等性;执行器结果表 | 1.4、3.2 |
| 8 | 异常直接抛穿循环 | 外层 except 返回空答案,误判成功 | 失败转结构化观察;记录终止原因 | 1.2、1.6 |
| 9 | 只检测重复动作,不检测观察 | 合理轮询被误杀;或空转漏检 | 指纹含动作 + 观察 + 世界版本 | 1.5 |
| 10 | 没有 trace,只看最终答案 | 无法回答「为什么」;回归无法复现 | 每步 JSONL;至少六字段 | 1.8、10.2 |
| 11 | 把墙钟超时交给 signal.alarm | 子进程仍在跑,文件被继续写 | 进程组 + SIGKILL / pidfd | 1.5、3.2 |
| 12 | 工具描述里暴露权限细节 | 诱导模型试探边界;注入面扩大 | 权限留在宿主侧契约,不下发模型 | 1.4、2.8 |
这张表说明什么:十二个陷阱里,只有第 1 个是「概念不清」,其余都是工程缺失;它们不会因为换模型而消失。第 3 章与第 4 章会把第 5、7、11 条展开成可运行的边界。
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能判断一个系统是不是 agent | 对三个例子给出判据与结论 | 1.1 |
| 能画出五阶段循环 | 标出哪一步由模型做、哪一步必须宿主做 | 1.2 |
| 能说出四层状态 | 每层给出内容、写入者、是否可压缩 | 1.3 |
| 能写工具契约 | 八字段齐全,含前置条件与幂等性 | 1.4 |
| 能设计预算 | 五分量 + 三层停止实现 | 1.5 |
| 能定位死循环 | 用 trace 的重复指纹与预算余额解释 | 1.6、1.8 |
| 能跑最小内核 | mini_loop.py 三种模式 + 单测全过 | 1.7 |
| 能读 trace | 用三条 jq 命令回答工具分布、失败、预算 | 1.8 |
Q1 一个系统每轮都把用户问题加上历史消息发给模型,模型只返回文本,没有工具。它是 agent 吗?为什么?
Q2 为什么 wait status 的低字节是 0x00 时,退出码不一定是 0?如果 status = 0x2a00,退出码是多少?
Q3 预算向量里,哪一个分量最容易被忽略,却最可能导致「以为停了其实没停」?
答案
A1 不是。它缺工具与闭环:没有动作改变外部世界,下一步也不依赖工具观察。历史消息只是会话状态,不构成 agent 的充分条件。若加入工具、让下一步依赖工具结果,并维护任务/控制状态,才跨过判据。
A2 wait status 是内核编码,不是退出码。低 7 位是终止信号,第 8 位是 core dump 标志;只有 WIFEXITED(status) 为真时,WEXITSTATUS(status) 才是退出码。status = 0x2a00 的低字节是 0x00,WIFEXITED 为真,WEXITSTATUS(0x2a00) = (0x2a00 >> 8) & 0xff = 0x2a = 42。低字节为 0 只说明「没有信号终止」,不说明退出码是 0。详见附录 B。
A3 墙钟(B_wall)。它不像 token 那样每步可见,也不像步数那样容易计数;当工具阻塞在 read() 或网络等待时,执行器的预算检查根本没有机会运行。必须在进程层用进程组、pidfd 或 cgroup 做超时,而不是只靠循环里的 if time.time() > deadline。
下一站:2 工具协议 把本章的「动作意图」变成三方都能校验的契约:JSON Schema、function calling 与 MCP 的两个版本。