Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1 Agent 的本质:循环、状态与工具

本章回答

一次模型调用与一个 agent 的分界线在哪?为什么「多轮对话」不等于「有状态」?循环靠什么停下来,状态该存哪几层,工具为什么要有契约而不是一个函数名?当模型反复调用同一个工具、观察却不变时,宿主应该在第几步介入?

读完你会

用「闭环、工具、状态」三个判据判断一个系统是不是 agent;画出五阶段循环与四层状态;为工具写出带前置条件、副作用、可逆性与幂等键的契约;用预算向量与进展度量设计终止条件;跑通一个 120 行的最小内核,并从 JSONL trace 里定位死循环。

先说结论:agent 不是「更长的 prompt」,而是一个带状态、带工具、带预算的闭环控制器。模型只是控制器里的策略函数:它读当前状态,产出一个动作意图;真正执行动作、改变世界、记录结果的是宿主。把这句话拆开,就得到本书后续十三章的全部主题——协议是动作的语法,沙箱是动作的边界,上下文是状态的投影,评估是控制器的验收,可观测性是控制器的黑匣子。

为什么这件事值得单独写一本书?因为 2023 年以来的多数 agent 事故,并不是模型「不够聪明」,而是系统工程没做对:循环没有终止条件,状态只存在聊天记录里,工具没有幂等键,失败没有 trace,权限只靠一句「不要做危险操作」。这些问题与模型能力正交,换一个更强的模型也不会自动消失。

这一章先把最小的骨架立起来。后面每一章都会在这个骨架上加一层:第 2 章把「动作」变成协议,第 3、4 章把「执行」关进沙箱,第 5、6 章处理「状态」的投影与记忆,第 7、8 章讨论策略与控制结构,第 9–12 章回答「怎么知道它做对了、怎么在出错时复原」。骨架错了,后面每一层都会歪。

Agent 循环:观察、决策、行动、更新、终止 横向读是「一步」,弧线读是「多步」。真正把 agent 与单次调用区分开的是弧线与中间那层 STATE/BUDGET:没有状态,下一步不知道上一步做了什么;没有预算,循环不知道何时停。图中「终止?」是判定节点,不是又一个模型调用——终止必须由宿主用可检查的条件执行。 一次 step 是五件事,不是一次模型调用 输入 / 目标 task + tools 观察 tool result 决策 policy / LLM 行动 tool call 更新 state + budget 终止? answer / abort 未终止:带着新观察回到循环;预算与步数每轮递减 STATE:世界状态 / 任务状态 / 会话状态 / 控制状态 BUDGET:steps · wall-clock · tokens · tool calls · money 终止条件必须三者同时成立:目标满足 ∧ 预算未耗尽 ∧ 无待处理工具结果 观测:每一步写一条 step 记录(决策、调用、结果、耗时、余额);没有记录就不算可复现
读图:横向读是「一步」,弧线读是「多步」。真正把 agent 与单次调用区分开的是弧线与中间那层 STATE/BUDGET:没有状态,下一步不知道上一步做了什么;没有预算,循环不知道何时停。图中「终止?」是判定节点,不是又一个模型调用——终止必须由宿主用可检查的条件执行。

动画:脉冲沿环前进表示一次 step;中心是 STATE 与 BUDGET。开启「减少动态效果」时只显示静态帧;页面不可见时动画暂停。

读图:横向是「一步」,弧线是「多步」。把 agent 与单次调用区分开的不是箭头数量,而是弧线闭合之后仍然存在的两样东西——状态与预算。状态让下一步知道上一步发生了什么,预算让循环知道何时必须停。没有这两样,所谓 agent 只是一个会重复调用 API 的脚本。

1.1 一次调用不是 agent:三个判据

先看三个真实系统,判断它们是不是 agent。

系统 A:客服问答。 用户提问,系统检索知识库,拼接提示词,调用一次模型,返回答案。没有工具调用,没有第二轮,没有跨请求状态。这是单轮 RAG,不是 agent。

系统 B:退款审批工作流。 用户提交退款,系统按固定顺序执行:查订单 → 查风控 → 若金额小于阈值自动通过,否则转人工。步骤是代码写死的,模型只在「生成给用户的解释」这一步被调用。这是工作流(workflow),不是 agent。它的控制流由人预先编排,模型不决定下一步做什么。

系统 C:代码修复助手。 它读 issue,自己决定先看哪个文件;调用 grep 找到相关函数;修改;跑测试;测试失败后读错误、再改;最多 20 步或 10 分钟。它有一个闭环、有工具、有跨步状态,还有预算。这是 agent。

三者都可能用同一个模型、同一套 API,差别不在模型,而在谁决定下一步、动作是否改变外部世界、状态是否跨步累积。

定义 1.1.1 · Agent

一个 agent 是一个六元组

A=(S, O, A, π, E, B) \mathcal{A} = (S,\ O,\ A,\ \pi,\ E,\ B)

其中:

  • SS 是状态空间,st∈Ss_t \in S 是第 tt 步开始时的完整状态;
  • OO 是观察空间,ot∈Oo_t \in O 是执行动作后从环境得到的结果;
  • AA 是动作空间,at∈Aa_t \in A 是策略产出的动作意图(工具名 + 参数,或终止);
  • π:S→Δ(A)\pi: S \to \Delta(A) 是策略,给出动作分布;本书不假设它一定由 LLM 实现;
  • E:S×A→S×OE: S \times A \to S \times O 是执行器,负责校验、授权、执行副作用并返回观察;
  • BB 是有限预算向量,见 定义 1.5.1。

一次 step 是

at∼π(⋅∣st),(st+1,ot)=E(st,at). a_t \sim \pi(\cdot \mid s_t),\qquad (s_{t+1}, o_t) = E(s_t, a_t).

当 EE 包含至少一个会改变 agent 之外世界的动作,且 st+1s_{t+1} 依赖 oto_t 时,系统才具备「闭环」;否则它只是把一次函数调用包装成了循环。

这个定义故意把 π\pi 与 EE 分开。很多实现把「模型输出」直接当成「已经执行」,于是把工具调用、参数校验、权限检查全塞进 prompt。这在 demo 里能跑,在生产里会出现三类问题:模型声称完成了但实际没执行;参数越权但没人拦;执行失败被模型当成成功继续往下走。把 π\pi 与 EE 分开,就是强迫宿主拥有一个可检查的执行层。

定义 1.1.2 · 轨迹

从初始状态 s0s_0 到终止状态 sTs_T 的序列

τ=(s0,a0,o0,s1,a1,o1,…,sT) \tau = (s_0, a_0, o_0, s_1, a_1, o_1, \ldots, s_T)

称为一条轨迹(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 的五个常见误解
  1. 「多轮对话就是 agent」——多轮只说明消息在累积;如果每轮都不依赖工具结果、也不改变外部世界,它仍是聊天。判据是闭环与工具,不是轮数。
  2. 「agent 一定要用 LLM」——定义里的 π\pi 可以是规则、搜索、强化学习策略或它们的组合。LLM 只是目前最通用的先验;把 agent 等同于 LLM 会让你忽略控制器本身的设计。
  3. 「工具越多,能力越强」——工具越多,选择空间越大,误用与注入面也越大。工具数量与可靠性通常呈倒 U 形;第 2 章会讲如何用命名空间、分层与按需发现来控制。
  4. 「状态就是 messages 数组」——messages 是会话状态的一种表示,且是有损的。任务约束、世界事实、预算余额必须有独立的结构化存放点,否则一次摘要就可能把它们吃掉。
  5. 「有终止条件就不会死循环」——终止条件必须由宿主执行且每步可检查。写在 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)

三个细节决定了实现质量:

  1. policy 的失败不是异常,是状态。 模型超时、返回非法 JSON、调用不存在的工具,都应该变成一条观察(例如 {"ok": false, "error": "EINVAL"}),让下一步有机会纠正;直接把异常抛穿循环,等于放弃自愈。
  2. execute 必须原子地返回「新状态 + 观察」。 如果工具已经产生副作用,但状态更新失败,下一轮会基于旧状态重复执行——这就是缺少幂等键时最危险的重复扣款/重复发信。
  3. 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 直接返回了空答案。
定理 1.2.1 · 有限预算下的终止上界

设预算为 B=(Bstep,Btool,Bwall,Btoken)B=(B_{\text{step}}, B_{\text{tool}}, B_{\text{wall}}, B_{\text{token}}),且每个 step 至少消耗 BstepB_{\text{step}} 中的 1 个单位、每次工具调用至少消耗 BtoolB_{\text{tool}} 中的 1 个单位。若执行器在预算不足时拒绝继续,则任意轨迹的长度满足

T≤min⁡(Bstep, Btool+1). T \le \min(B_{\text{step}},\ B_{\text{tool}} + 1).

进一步,若墙钟与 token 也在每步单调消耗,则 TT 还受这两者的上界约束。

证明

每个 step 开始时 spend_step 使已用步数加 1,最多执行 BstepB_{\text{step}} 次;第 Bstep+1B_{\text{step}}+1 次申请会失败并触发终止。工具调用同理:最多 BtoolB_{\text{tool}} 次成功调用,之后要么终止,要么继续执行不消耗工具的步骤;若循环的每个 step 都必然调用工具,则 T≤BtoolT \le B_{\text{tool}}。取两者较小值即得。墙钟与 token 的约束是同一论证的连续版本:每步的消耗非负,累计到上界即停。∎

∎

注意定理的前提:每个 step 必须真的消耗预算。如果实现里存在「空转 step」——不调用工具、不增加计数、只把同一条消息再喂给模型——上界就不成立。第 1.6 节的真实事故,正是空转 step 没有被计入预算。

终止不是「让模型别继续」,而是「让宿主无法继续」

把终止条件写在 prompt 里,等价于把刹车交给乘客;把终止条件写进执行器,才是司机踩刹车。工程上最稳的做法是:预算检查放在 spend_* 函数里,任何路径想继续都必须先申请额度,申请不到就抛出一个受控的终止信号,由最外层统一收尾。

关于循环的三个实现错误
  1. while True + 模型自己说停——模型没有执行权。必须有宿主侧的 done() 与预算检查。
  2. 把异常当成终止——异常是「不可继续」的一种,但必须记录原因与最后状态;直接 return "" 会让上层误以为任务成功。
  3. 只按 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,需要时再让模型「回忆」。这在短任务里可行,在长任务里必然出问题,因为消息会被截断、摘要、重排,而任务约束与预算余额不能被有损压缩。

定义 1.3.1 · 四层状态

一个可运维的 agent 状态至少分为四层:

层 内容 谁写 是否可有损 生命周期
世界状态 SWS_W 文件、数据库、外部系统、工单的真实状态 工具执行后由宿主回写 否 跨 step,可能跨 session
任务状态 STS_T 目标、约束、验收条件、子目标、完成度 规划器/宿主 否 一个 task
会话状态 SSS_S 消息、工具结果、用户偏好 循环 是(可压缩) 一个 session
控制状态 SCS_C 步数、预算余额、重试计数、终止原因 执行器 否 一个 run

四层合起来是 st=(SW,ST,SS,SC)s_t=(S_W,S_T,S_S,S_C)。任何一层缺失,都会导致一类特定故障:缺世界状态会重复执行;缺任务状态会目标漂移;缺会话状态会失忆;缺控制状态会停不下来。

这四层不是学术分类,而是四种不同的持久化需求。世界状态应该以外部系统为准,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 仍然准确。

例题 1.3.1 · 一次摘要事故的复盘

某代码 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 章)。
方法 1.3.1 · 状态设计五问

在写第一行循环之前,先回答:

  1. 世界状态以谁为准? 数据库、git、工单系统,还是 agent 的内存?给出读取方式与版本字段。
  2. 哪些字段不可压缩? 目标、硬约束、验收条件、预算余额、权限范围。把它们列成 schema。
  3. 崩溃后如何恢复? 重放 trace、从检查点恢复,还是重新开始?恢复时如何避免重复副作用?
  4. 哪些状态可以给模型看? 世界状态里可能有 PII、密钥、他人数据;注入前必须脱敏(第 10.4 节)。
  5. 状态如何淘汰? TTL、容量上限、删除请求如何传播到记忆与缓存(第 6.6 节)?

五问里只要有任意一问没有答案,先别急着接模型;补完再写循环,返工率会低很多。

关于「状态爆炸」

状态不是越全越好。每多存一个字段,就多一处脱敏、迁移、序列化与一致性负担。一个实用的边界是:只存「重新计算很贵」或「无法重新计算」的东西。文件内容可以重读,不必存;用户刚说过的偏好无法重算,必须存;工具返回的临时 ID 可以重取,但「这个 ID 属于哪个任务」必须存。

1.4 工具:从函数到动作接口

很多人把工具理解成「一个能被模型调用的函数」。这个理解漏掉了工程上最重要的部分:工具是动作接口,它必须说明自己能做什么、在什么前提下能做、做完之后世界变成什么样、失败能否重试、代价是多少、需要什么权限。

定义 1.4.1 · 工具契约

一个工具契约至少包含八个字段:

字段 作用 缺失时的故障
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 的三个差别

维度普通 APIagent 工具
调用者知道业务语义的代码只知道自然语言与 schema 的模型
参数来源上游代码构造可能来自网页、文件、另一个工具的输出(不可信)
失败处理调用方写 try/except失败要变成观察,供模型纠正;且不能重复副作用
权限进程凭证按调用者、任务、数据范围动态收敛
可观测性日志每次调用是一个 span,带参数摘要、结果、成本

这张表说明什么:把一个内部 API 直接暴露成工具,通常会同时放大三类风险——模型误用、参数注入、权限过宽。工具层需要一层「适配器」,做参数归一化、权限检查、结果裁剪与错误翻译,而不是把 REST 端点原样贴给模型。

1.4.2 结果封装:让失败也能被决策

工具返回什么,决定了模型下一步能做什么。只返回成功数据,失败就只能靠异常;返回整个 HTTP 响应,模型会被无关字段淹没;返回自然语言,宿主又无法稳定处理。一个够用的结果封装至少包含六个字段:

字段类型说明
okboolean是否成功;失败时其余字段仍要可读
dataobject成功时的结构化结果,按工具契约裁剪
errorobjectcode、message、retryable、suggested_action
refsarray结果依赖的外部对象引用(路径、ID、版本号)
costobject本次调用的 token、金额、延迟
observed_atstringISO 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。没有这两个字段,策略只能看到「失败」,然后凭运气选择重试或放弃;有了它们,循环可以把「不可重试」直接升级成终止或人工审批,而不是烧完预算才发现无解。

关于工具的五个误解
  1. 「描述写清楚就够了」——描述影响模型选择,但不构成约束。真正的约束在 schema、前置条件与执行器。
  2. 「参数校验是模型的事」——模型可能被注入,参数可能来自不可信网页。校验必须在宿主侧再做一次,且以宿主为准。
  3. 「工具失败就把异常抛给模型」——抛栈信息既不可读也可能泄密。应翻译成结构化错误:错误码、可重试性、建议动作。
  4. 「幂等是数据库的事」——agent 的重试发生在工具层;没有幂等键,数据库事务也救不了重复调用。
  5. 「工具越原子越好」——原子工具组合灵活,但步数与 token 成本高;粗粒度工具便宜,但难以授权与回滚。粒度要按「权限边界 + 可逆性」切,不是越细越好。
工具是「动词」,不是「按钮」

按钮只告诉你「按下去会发生什么」,动词还告诉你「谁可以按、在什么条件下按、按错了怎么撤销」。agent 的工具设计应该像设计一组动词:read_file、apply_patch、run_tests、request_approval。每个动词的语义边界清楚,组合起来才可控。

1.5 控制:预算、终止与死循环

预算不是「防止账单爆炸」的保险丝,而是控制器的一部分:它决定策略在多深的搜索里还能保持理性。一个没有预算概念的 agent,在遇到不确定时会倾向于「再试一次」,而每一次尝试都可能带来新的副作用。

定义 1.5.1 · 预算向量

预算是一个五元组

B=(Bstep, Btool, Bwall, Btoken, Bmoney) B = (B_{\text{step}},\ B_{\text{tool}},\ B_{\text{wall}},\ B_{\text{token}},\ B_{\text{money}})

每个分量都是有限的非负数,并在每步单调消耗。执行器在每次动作前检查对应分量;任一分量耗尽即触发终止,终止原因写入控制状态。预算可以按层级分配(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 死循环检测:动作指纹与进展度量

重复动作有两种:一种是合理的重试(网络抖动),一种是病态的空转(观察没变,模型还在做同一件事)。区分它们需要「进展」的概念。

定理 1.5.1 · 进展度量下的终止

设存在一个良基的进展度量 Φ:S→N\Phi: S \to \mathbb{N},且对任意「有进展」的 step 有 Φ(st+1)<Φ(st)\Phi(s_{t+1}) \lt \Phi(s_t)。若执行器保证:

  1. 每个 step 消耗至少一个预算单位;
  2. 连续 kk 步的动作指纹与观察指纹完全相同则强制终止;
  3. 预算有限;

则任意轨迹有限,且终止原因可归入「目标满足 / 预算耗尽 / 无进展 / 不可继续」之一。

证明

由条件 1 与有限预算,步数有上界(定理 1.2.1)。若不触发预算终止,则轨迹无限,说明 Φ\Phi 只能有限次严格下降;此后所有 step 都不改变 Φ\Phi。若这些 step 的动作与观察指纹最终稳定,条件 2 在至多 kk 步后触发;若指纹持续变化但 Φ\Phi 不降,则动作在状态空间里游走,仍受预算上界约束。因此不存在无限轨迹。终止原因由执行器在触发点写入,四类互斥。∎

∎

这个定理的工程价值在于把「防死循环」拆成三个可实现的检查:预算、指纹、进展度量。三者不需要都完美,但至少要有一个能在有限步内触发。

一个够用的指纹实现:

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 美元。平均分配会导致两个问题:前面的廉价步骤花不掉预算,后面的昂贵步骤没额度;或者反过来,前置探索烧光额度,真正关键的验证做不了。

一个可操作的分配顺序是:

  1. 先扣输出预留。 每次模型调用都要给输出留 token;输入塞满会导致回答截断,比输入超限更难发现。
  2. 再扣验证预算。 测试、编译、人工审批这些「证明做对了」的步骤,必须优先于更多探索。
  3. 再扣重试预算。 每个工具单独设重试上限,不要把全局重试次数当成所有工具共享。
  4. 最后才是探索预算。 搜索、读文件、试错用剩余额度;额度不足时策略应降级为「少探索、多确认」。
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。

方法 1.5.1 · 终止条件检查清单

发布一个 agent 前,逐条确认:

  1. 预算向量的五个分量都有值,且都能被执行器读取;
  2. 每个 step 都经过 spend_*,没有绕过预算的「快捷路径」;
  3. 墙钟超时能在工具阻塞时生效(进程组 / pidfd / cgroup);
  4. 连续 kk 步「动作 + 观察」指纹相同的检测已接入;
  5. 有独立的进展度量(完成子目标数、测试通过数、剩余错误数),且不依赖模型自评;
  6. 终止原因写入 trace,并能区分四类;
  7. 人工中止是一个正常路径,不是异常。

七条里任何一条缺失,都可能让「最多 10 步」变成「跑了 10 小时」。

关于预算的四个误解
  1. 「预算就是 max_tokens」——token 只是五分之一;工具配额、墙钟、金额、步数都可能先耗尽。
  2. 「超时用 signal.alarm 就够」——alarm 在阻塞系统调用上可能被重启(SA_RESTART),且只作用于当前线程;子进程不会被自动杀死。要用进程组 + SIGKILL,或用 pidfd 等待。
  3. 「重试不花钱」——重试消耗预算、放大尾延迟,还可能重复副作用。重试必须有上限、退避与幂等键。
  4. 「预算可以事后补」——补预算等于允许无限循环;如果确实需要延长,应该由人批准一个新的 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,模拟模型的一种真实行为:观察没有提供新信息,但策略仍然选择同一个动作。模型侧的原因是它没有「进展」的概念;宿主侧的原因是循环只检查了「是否完成」,没有检查「是否有进展」。

例题 1.6.1 · 三层修复

第一层:预算。 max_steps=4 让循环在 4 步后停止,避免无限运行。这是必要的,但不是充分的:线上更常见的是 200 步上限,模型在第 3 步就卡住,却白烧了 197 步。

第二层:指纹。 连续 3 步「动作 + 观察」相同即终止。修复后,同一任务在第 3 步以 no_progress 结束,成本从 200 步降到 3 步。

第三层:进展度量。 任务状态里维护 subgoals_done。如果连续 kk 步没有新增完成的子目标,强制重规划或转人工。这一层能捕获「动作在变、观察在变,但目标没有推进」的游走。

三层的关系是:预算是硬上界,指纹是局部检测,进展度量是全局检测。只做第一层,成本高;只做第二层,可能误杀合理的轮询;只做第三层,反应慢。三层都做,才能既省钱又不误杀。

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 记录至少要有六个字段:

字段例子用途
step3顺序、重放
decision{"tool":"fs.write","args":{...}}模型意图;区分「想做」与「做成」
result{"ok":true,"bytes":2}执行结果;失败时含错误码
state四层快照摘要定位状态漂移
budget_before{"steps_left":5,"tools_left":4}解释终止原因
duration_ms12找慢步骤;生产环境还应带 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 是否健康,还需要把它聚合成指标。最小的一组只有四个:

成功率=#{stop_reason=done}#{runs},平均步数=1N∑i=1NTi \text{成功率} = \frac{#{\text{stop&#95;reason}=\text{done}}}{#{\text{runs}}},\qquad \text{平均步数} = \frac{1}{N}\sum_{i=1}^{N} T_i

工具错误率=#{tool result ok=false}#{tool calls},单次成本=1N∑i=1N(cin⋅ini+cout⋅outi+ctool⋅callsi) \text{工具错误率} = \frac{#{\text{tool result ok=false}}}{#{\text{tool calls}}},\qquad \text{单次成本} = \frac{1}{N}\sum_{i=1}^{N} \left(c_{\text{in}} \cdot \text{in}_i + c_{\text{out}} \cdot \text{out}_i + c_{\text{tool}} \cdot \text{calls}_i\right)

四个指标要一起看。成功率上升但平均步数翻倍,可能是模型更谨慎,也可能是它在试错;工具错误率下降但单次成本上升,可能是重试策略从「快速失败」变成了「反复尝试」。只看一个指标,很容易把退化当成改进。

还要按终止原因分层,而不是只算总成功率:

终止原因含义健康信号
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 * 绕过。

方法 1.8.1 · 用 trace 做一次 15 分钟排障
  1. 看终止原因:done / max_steps / no_progress / error。先确定是「做完了」「没做完但停了」还是「崩了」。
  2. 看最后 3 步:动作、观察、状态快照。多数问题在最后三步已经显形。
  3. 看重复:按工具名与参数哈希分组,找重复次数最高的动作。
  4. 看失败:筛 ok=false,按错误码聚合;EINVAL 是参数问题,EPERM 是权限问题,EBUDGET 是预算问题。
  5. 看时间:按 duration_ms 排序,找最慢的 span;如果工具占了 80%,问题不在模型。
  6. 看状态漂移:比较第 1 步与最后一步的 task.constraints;如果约束消失了,是上下文压缩的问题(第 5 章)。
  7. 写一条回归用例:把这次的最小复现输入保留下来,加入第 9 章的评估集。

七步做完还定位不到,再去看完整日志;先看 trace 能避免在几万行日志里迷路。

例题 1.8.1 · 从 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-28Architecture、Tools1.4 工具契约、2.4 原语背景
OCI Runtime Spec 1.3process、config3.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 / pidfd1.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
即时自测 · 1

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 的两个版本。