《从零理解 AI Agent》系列 · 第 2 / 7 篇
上一篇我们说到,Agent 的本质是"想一步,做一步,看一步"的循环。这一篇,我们把这台发动机拆开看:循环每一轮到底发生了什么、它怎么停下来、出了事故怎么办。在开始之前,还得先补一点 LLM 的底层知识——它们是理解后面所有内容的地基。
一、先懂一点 LLM:模型是怎么"说话"的
1.1 什么是 Token?
模型不认识"字",它只认识 token。可以把 token 理解为"文字积木":
hello world→ 切成['hello', 'world'],2 个 token,一个英语单词是一个token人工智能→ 大约 2~3 个 token(具体取决于模型的分词器)
分词器的主流算法叫 BPE(Byte Pair Encoding,字节对编码),核心思想是把文本中高频出现的片段合并成一块积木。
1.2 模型的输出是一点一点往外蹦的
这是最关键的一点:模型生成回复时,是一个 token 接一个 token 生成的,每次只能猜"下一个 token 是什么"。这带来三个重要推论:
-
模型天然就是流式输出的。 你在 ChatGPT 里看到"打字机效果",不是产品刻意做的动画,而是模型本来就是这个工作方式。
-
工具调用的 JSON 要"攒够了"才能解析。 模型想调用工具时,会输出一段 JSON,但这段 JSON 也是一个个 token 蹦出来的:
'{"na' 'me":"rea' 'd_file' ...必须等 JSON 攒完整才能解析执行。
-
Response Prefill(响应预填充)能操纵模型行为。 既然模型是"接着写",那我们提前塞给它半句话,它就会顺着往下写。比如预填
{"name":"browser_,模型就只能接着补全一个浏览器相关的工具调用。这是个很实用的工程技巧。
1.3 QKV 机制与"上下文腐烂"
生成每个 token 时,模型会拿着当前 token 的 Q(Query,查询向量),去跟前面所有 token 的 K(Key,键向量)做匹配,找到最相关的那些,读取它们的 V(Value,值向量),综合出下一个 token。
这个机制有个要命的副作用:上下文越长,模型对每一条信息的"记忆力"就越差。信息一多,注意力就被摊薄了——这就是所谓的上下文腐烂(Context Rot)。记住这个概念,第 4 篇讲上下文工程时会反复用到。
1.4 KV Cache:缓存重复计算
生成第 101 个 token 时,需要拿它的 Q 跟前 100 个 token 的 K 做点积;生成第 102 个 token 时,前面 100 个 token 的点积又要重新算一遍——这是纯浪费。所以推理引擎会把算过的结果缓存起来,这就是 KV Cache。它是模型推理层最重要的优化,没有之一。第 3、4 篇讲工具延迟加载和提示词缓存时还会提到它。
二、ReAct 循环:一轮到底发生了什么?
Agent Loop 本质上是一个 while 循环。每一轮大致分五步:
while True:
# 1. 准备上下文
# 检查上下文是不是快爆了,快爆了就压缩
# (三种手段:Snip 删旧消息 / Microcompact 局部摘要 / Auto-compact 全局摘要)
# 2. 调用模型 API
# 模型流式返回。还没输出完,已经识别出的工具调用就可以开始执行
# "边说边执行"——但不冲突的操作才能并行
# 3. 决定是否继续
# 满足退出条件就跳出循环
# 4. 执行工具
# 工具报错时,错误信息要写得详细,让模型能理解错在哪、怎么改
# 5. 构建下一轮状态
# 处理杂活:检查 Skill、记录已消费的命令……
循环什么时候停? 以 Claude Code 为例,有 7 种退出条件:
- 模型这轮没有发起任何工具调用(它认为自己干完了)
- 流式传输被中断(用户按了 Esc)
- 工具执行被中断
- Hook 阻止了继续执行
- 超过了最大轮数(防止无限循环的保险丝)
- 上下文过长,API 拒绝请求
- 压缩之后上下文还是过长,无法恢复
状态追踪:Agent 得知道自己"跑到哪了"。
一个健壮的 Agent Loop 需要持续追踪这些状态:
- 现在到第几轮了?——用来判断是不是该强制停下(保险丝)
- 上一轮为什么选择了继续?——上一轮可能一切正常,也可能是错误恢复,也可能是压缩后重试
- 压缩执行到哪了?——是否触发了紧急压缩、压缩后 token 降了多少
- 输出被截断了几次?——模型输出到一半被截停,恢复一次可以,两次可以,三次就该放弃了
- 有没有被挂起的任务?
实时反馈:边跑边说。
早期的一问一答体验很糟糕:你输入一句话,白屏几秒,蹦出一段内容,然后工具调用,又白屏几秒……用户完全不知道 AI 进行到哪一步了。
现在的 Agent 都采用流式架构:模型边输出,边调用工具,中间状态实时展示给用户。实现上依赖**生成器(Generator)**这样的惰性求值机制——生产一点,消费一点。
三、流式响应:SSE 的舞台
3.1 SSE vs WebSocket
很多人以为流式输出用的是 WebSocket,其实主流方案是 SSE(Server-Sent Events)。两者区别在于:
- WebSocket 是全双工通信:连接建立后,客户端和服务端可以互相随时发消息。
- SSE 是 HTTP 的一种特殊模式:客户端和服务端建一条长连接,只有服务端能往客户端推消息。
而 LLM 流式输出的本质,就是服务端向客户端一个一个地推 token,客户端根本不需要向服务端发消息。既然单向就够了,为什么要用更重的 WebSocket?而且 SSE 自带重试机制——连接断了,客户端可以自动重连。
3.2 流式输出中的工具调用:早一秒还是晚一秒?
模型流式输出工具调用的 JSON 时(比如 {"file_path":"src/utils.ts"}),解析时机是个权衡:
- 过早解析:JSON 还没攒够,可能调用错误的工具,甚至直接崩溃
- 过晚解析:攒太久了才解析,白白浪费等待时间,效率低
3.3 哪些工具可以"边说边调用"(并发)?
Claude Code 有一套并发安全判断:
| 工具 | 性质 | 能否并发 |
|---|---|---|
| Read(读文件) | 只读不写 | ✅ 可以并发 |
| Glob / Grep(搜索) | 只搜不改 | ✅ 可以并发 |
| Edit(改文件) | 写入操作 | ❌ 必须串行 |
| Bash 命令 | 看命令 | ls 可以并发,rm 必须串行 |
工具结果按什么顺序返回?
Claude Code 按工具调用的发起顺序返回结果,目的是保证工程上的顺序性、可读性,方便调试——哪怕有些工具是并发执行的。
3.4 一个有趣的问题:SSE 是单向的,那审批时你怎么把 "yes" 发给 Agent 的?
流程是这样的:
- 流式输出:模型一个 token 一个 token 地输出
- 流式结束,弹出审批确认框
- 你敲下 yes——这其实是发送了一个普通的 HTTP 请求
- 工具执行,然后开启一条新的 SSE 流,进入下一轮循环
也就是说,SSE 只负责"AI 对你说",而"你对 AI 说"走的是普通请求。每轮循环都是一条新的 SSE 流。
四、Loop 的三种事故与三根保险丝
Agent 跑长任务时,最常见的三种事故:
- 死循环:反复调用同一个工具,做同样的事
- Token 烧穿:无限续写,把预算烧光
- 输出截断:模型输出到一半被截断,而它自己并不知道
应对它们,需要三根保险丝。
保险丝一:工具死循环检测
以 OpenClaw 的设计为例,它给每次工具调用打上"指纹"——对 工具名 + 序列化参数 做 SHA256 哈希,生成唯一标识;同时记录结果指纹——同样的调用、同样的参数、同样的结果,才判定为"无进展"。在此基础上有四种检测器:
- 通用重复检测:同一工具、同样参数,触发 10 次阈值 → 警告(但不阻断,因为有些任务就是需要重试)
- 无进展轮询检测:不光看参数,还看结果。如果状态长时间没变化,说明 Agent 该换个思路了
- Ping-Pong 检测:捕捉两个工具交替调用的模式,比如
read_file → write_file → read_file → write_file → ...来回打乒乓球 - 全局熔断:以上三种都算"无进展",累计 30 次无进展 → 强制停止,没有例外
保险丝二:Token 预算控制
每个任务预先设定 Token 预算,花超了就停。两个细节:
- 90% 预警:在接近预算时注入提示:"已完成 Token 目标的 87%(26170/30000),继续工作——不要总结"。注意最后那句"不要总结"——模型在慌的时候喜欢草草收场,得明确禁止。
- 递减回报检测:如果模型在反复续写,而每次的增量越来越小(+3000 → +2000 → +400 → +200),连续两次递减就说明它在空转,立即停止。
保险丝三:输出截断恢复
每个模型都有 max_output_tokens 参数(Claude 默认 8192)。模型想说的话超过限制就会被"腰斩",而它自己不知道。Claude Code 的处理三步走:
- 扩容:8K 不够,提到 64K
- 注入恢复消息:"输出 Token 限制被触发,直接从断点继续——不要道歉,不要回顾你在做什么。如果话说到一半被截断,从那个思路接着说,把剩下的工作拆成更小的块。"
- 认栽:恢复 3 次都不行,就把不完整的结果返回给用户,并明确标记"输出被截断"
五、API 报错了怎么办?
Agent 一跑几十轮,每轮都要调模型 API,出错是家常便饭。核心思路是:先分类,再决策。
5.1 第一步:错误分类
| 类别 | 典型状态码 | 特点 |
|---|---|---|
| 可重试 | 429(限流)、529/503(服务过载)、408(超时)、ECONNRESET(连接重置) | 等一等再试,可能就好了 |
| 不可重试 | 400(请求错误)、401(未授权)、403(拒绝访问)、402(余额不足)、500(服务器错误) | 重试也没用,得改请求或换方案 |
| 需要降级 | 连续多次 529、流式连接反复断开 | 重试救不了,得换条路 |
5.2 决策一:单次失败怎么重试?——指数退避 + 随机抖动
为什么不能固定间隔重试?想象服务端过载时,成千上万个客户端都在"1 秒后重试",那么 1 秒后服务器迎来第二波洪峰——这叫重试风暴。
正确姿势:
- 指数退避:第一次等 1 秒,第二次等 2 秒,第三次等 4 秒……每次翻倍
- 随机抖动:每次重试间隔里随机加 0~100ms,避免所有客户端"踩着点"同时到达
- 尊重 Retry-After:如果服务器明确说了"X 秒后再来",就听它的
- 连续失败:考虑模型降级,比如切换到更便宜的低配模型
流式连接偶尔卡住怎么办?
这是个隐蔽的坑:TCP 协议有个半开状态(Half-Open)。当用户网络抖动(比如切换 WiFi),TCP 连接可能已经断了,但 SSE 维持的 HTTP 长连接在浏览器层面毫无感知,会一直挂着干等数据。
解法是服务端心跳 + 客户端看门狗:
- 服务端心跳:服务端定期发心跳包(SSE 可以发注释帧,客户端无感知,但能证明"我还活着")
- 客户端看门狗:客户端记录最后一次收到数据的时间,一旦超过阈值,判定连接已死,主动重连
5.3 决策二:重试不管用,怎么降级?
- 流式 → 非流式:Claude Code 在流式反复失败后降级为非流式请求(降低服务端压力)
- 模型降级:换一个模型顶上
5.4 决策三:多 Provider 容灾
OpenClaw 把故障分得更细:
- 临时故障(网络抖动、临时过载):走冷却时间,指数退避
- 持久故障:走单独的禁用通道,退避基数直接拉到 5 小时、最长 24 小时——反正短时间内别想它了
- 提前备好"兄弟模型":主模型彻底不行时无缝切换
小结
这一篇我们看到了 Agent 的"心跳":
- 循环五步:准备上下文 → 调 API → 判断是否继续 → 执行工具 → 构建下一轮状态
- 退出条件有 7 种,从"干完了"到"上下文爆了"各不相同
- 流式输出用 SSE 而非 WebSocket,因为单向推送就够了
- 三根保险丝:死循环检测(四检测器 + 全局熔断)、Token 预算(预警 + 递减回报)、截断恢复(扩容 → 注入 → 认栽)
- API 容错三板斧:分类 → 退避重试 → 降级容灾
心跳有了,但 Agent 还只能"想",不能"做"。下一篇我们讲工具系统——模型到底是怎么"调用"函数的,MCP 和 Skills 是怎么回事,以及为什么给 Agent 的手脚戴上权限的镣铐。