第 2 页的文章列表

第四章 上下文与指令管理:模型的"记忆"和"世界观"如何维护

第三章我们看到,harness 坚持无状态请求:每次采样都把完整历史重新发给模型,服务端不保存任何会话。 这把“记忆”的责任全部压回了 harness 一侧:历史要自己攒、窗口要自己盯、超长要自己压缩、环境信息要自己喂。 本章回答四个问题:发给模型的那份“上下文”到底由什么构成?环境事实以什么形式注入?指令分几层、从哪来?历史滚到装不下时怎么办?


4.1 为什么“记忆”是 harness 最难的工程问题

一个新人常犯的想象是:上下文不就是“聊天记录”吗?把用户和模型说过的话按顺序存起来,每次请求带上就行。

真实场景里,这个“聊天记录”面对的是一团互相拉扯的约束:

  • 模型是无状态的,窗口是有限的。 每次请求都要自足可解释(第 3 章的 store=false),但上下文窗口有硬上限——从几万到一两百万 token 不等。而历史只会单调增长:用户的话、模型的话、思考记录、工具调用、工具吐出的成千上万行日志……
  • 模型需要知道的远不止对话。 今天几号、当前在哪个目录、用什么 shell、哪些路径能读写、网络通不通、项目里有哪些约定(AGENTS.md)、当前是什么协作模式、上一次压缩发生在什么时候……这些“世界观”信息都不在聊天记录里,但模型缺了它们就会犯错。
  • 缓存要求前缀稳定。 第 3 章讲过,服务端 prompt cache 命中的前提是请求前缀逐字节不变。上下文怎么追加、什么时候动、动哪里,直接决定账单大小。
  • 信息会过期。 目录切换了、权限变了、AGENTS.md 被用户改了、模型切换了——旧的事实不能赖在上下文里,模型读到过期信息比读不到信息更危险。
  • 注入的东西必须有界。 一条命令输出可能有几十兆,一份 AGENTS.md 可能被写成论文。任何注入上下文的片段都必须有大小上限,否则一个工具结果就能把窗口撑爆。

Codex 对这团乱麻的解法,可以概括为一条核心原则:

把“说过的话”(对话历史)和“当前的事实”(世界状态)分开管理。历史只追加、不重写;事实按状态快照做差分注入;装不下时,压缩历史但重建事实。

下面逐层拆开。


4.2 一次采样请求的解剖:上下文里到底有什么

先看最终发给模型的请求体。它由三大部分组成(第 2、3 章已经露过面):

graph TD
    REQ["一次采样请求"] --> INS["顶层指令 instructions<br/>(系统级基础指令)"]
    REQ --> TOOLS["工具清单 tools<br/>(本步骤可见工具的 JSON Schema)"]
    REQ --> INPUT["输入历史 input<br/>(一串有序的 item)"]
    INPUT --> I1["用户消息:真人的话 + 部分注入片段"]
    INPUT --> I2["助手消息:模型的最终回复"]
    INPUT --> I3["推理记录:加密思维链 + 摘要"]
    INPUT --> I4["工具调用 & 工具结果(成对出现)"]
    INPUT --> I5["开发者消息:harness 注入的状态/指令片段"]
    INPUT --> I6["压缩标记:摘要与窗口边界"]

注意历史里的消息有三种角色,语义截然不同:

角色 谁在说话 典型内容
user(用户) 真人,或 harness 伪装成用户注入的片段 真人输入;AGENTS.md 指令;harness 的环境片段(部分刻意用用户角色,见 4.5)
assistant(助手) 模型 最终回复文本;工具调用
developer(开发者) harness 自己 环境状态、权限说明、时间提醒、预算提醒、模式切换通知

顶层 instructions 是第四种声音——系统级基础指令(工具怎么用、行为准则),它不属于历史,每次请求单独携带。第 3 章讲过它跟着模型档案走:每个模型有自己的基础指令模板,用户也可以用配置整体覆盖(覆盖时会记录来源:来自模型档案还是用户自定义)。某些精简传输格式(Responses Lite)下,它会被改写成历史开头的一条开发者消息,语义不变。

一个容易忽略的事实:harness 往上下文里塞的东西,条目数量和种类远超对话本身。 环境说明、权限清单、时间、预算、AGENTS.md、多 agent 模式说明、插件用法提示……这些注入片段统称上下文片段(context fragment)。Codex 给它们立了一套统一的规矩。


4.3 片段协议:所有注入都必须是“带标签的结构体”

harness 内部有几十种需要注入上下文的内容:AGENTS.md、环境信息、权限说明、当前时间、token 预算提醒、中断标记、子 agent 来信、模型切换通知、hook 注入的额外上下文……如果每种都各自往历史里 push("一段字符串"),系统很快就会失控:无法识别哪些是自己注入的、无法在过期时替换、无法统计大小、压缩时无法判断哪些该重建。

Codex 的约束是:任何注入上下文的片段,都必须实现一个统一的片段接口,定义成一个结构体。这个接口要求每种片段回答四个问题:

  1. 我以什么角色出现? 用户还是开发者?
  2. 我的开始/结束标签是什么? 比如 <current_time_reminder> ... </current_time_reminder>,包裹在正文外层;
  3. 我的正文是什么? 渲染成最终给模型看的文本;
  4. 我是否必须独占一条消息? 大多数片段可以和同类片段合并成一条消息,少数(如审查策略指令)必须独立成条,便于审计。

这套设计最精妙的地方是标签的双重身份

  • 模型,标签是结构化信号——“这是 harness 提供的环境事实,不是用户说的话”,XML 式标签也是模型最容易学会遵守的格式;
  • harness 自己,标签是日后认出自己的依据。历史是只追加的,harness 需要随时扫描历史、回答“我上次注入的 AGENTS.md 还在不在?”“当前权限说明是哪条?”——靠的就是逐条匹配标签。识别不出来的片段,按普通对话内容对待。
flowchart LR
    F["片段结构体<br/>(角色 + 标签 + 正文)"] -->|"渲染"| H["写入历史<br/>带标签的消息"]
    H -->|"下次扫描"| M{"标签匹配?"}
    M -->|"匹配"| OWN["识别为 harness 注入<br/>可替换 / 可判定是否仍在"]
    M -->|"不匹配"| USER["普通对话内容<br/>原样保留"]

片段还有“可合并”与“独占”之分:同角色、可合并的片段会被拼进同一条消息(减少消息条目数,也利于缓存前缀稳定);独占片段各自成条。

一个旁证能说明这套规矩的严格:连 hook(外部扩展)注入的上下文也走同一个片段接口,扩展拿到的是结构化的贡献点,返回的也是带角色、带标签的片段对象——harness 不接受扩展直接往历史里塞裸字符串。

还有一个容易被忽略的细节:这些 harness 注入的“伪用户消息”,对第三方消费者是隐形的。当扩展(记忆巩固、技能系统等)读取“对话历史快照”时,harness 会把所有带标签的注入片段过滤掉——扩展看到的是真人与模型的真实对话,harness 的私房标注不会污染外部视角。同一份历史,给模型看的是“全集”,给扩展看的是“净集”。


4.4 世界状态:把“当前的事实”从“流水账”里分离出来

片段协议解决了“一条注入长什么样”,但没解决一个更根本的问题:环境信息是会变的,而历史是不能改的。

设想最朴素的做法:每次采样前把“当前目录、日期、权限、AGENTS.md……“拼一段塞进历史。后果是灾难性的——同一份环境说明会在历史里重复几十次,窗口迅速被废话撑满,而且模型会读到一堆互相矛盾的旧快照(“工作目录是 /a”……“工作目录是 /b”……),它不知道该信哪条。

Codex 的解法是引入**世界状态(World State)**这个一等概念,把上下文内容劈成两半:

  • 对话历史:流水账。谁在什么时候说了什么、调了什么工具、结果如何——只追加,不修改(第 1 章的核心原则);
  • 世界状态:此刻的事实。当前目录、日期、权限画像、AGENTS.md 内容、协作模式、模型信息、多 agent 模式……状态只关心“现在是什么”,不关心“怎么变成这样的”。

世界状态被切成若干区段(section),每个区段有一个稳定 ID 和一份可序列化的快照:环境、AGENTS.md、权限、模型指令、个性、协作模式、工具延迟加载信息、插件/应用用法提示、实时语音状态、上下文窗口信息……扩展也可以注册自己的区段。

4.4.1 差分注入:只告诉模型“变化了什么”

每个步骤边界(第 1 章讲过,快照都在步骤边界冻结),harness 做这样一件事:

flowchart TD
    A["步骤边界:构建当前世界状态"] --> B["逐区段生成快照"]
    B --> C{"与上次模型可见的<br/>基线快照对比"}
    C -->|"首次 / 基线丢失"| D["全量渲染该区段"]
    C -->|"内容相同"| E["不产生任何片段"]
    C -->|"内容变化"| F["只渲染变化区段的更新片段"]
    D --> G["片段写入历史"]
    F --> G
    G --> H["基线更新为当前快照"]

也就是说,稳定的环境事实在整个会话里原则上只说一次;目录切换、权限调整、AGENTS.md 被改,才会产出一条“更新通知”。通知的措辞经过专门设计,带有明确的替换语义。比如 AGENTS.md 发生变化时,新片段的正文开头是:

“以下 AGENTS.md 指令替换此前提供的全部 AGENTS.md 指令。”

如果 AGENTS.md 被删除,则注入:“此前提供的 AGENTS.md 指令不再适用。”

模型不需要翻阅历史去推断“哪条指令还有效”——每条更新都显式声明它与旧状态的关系。这比“让模型自己看到矛盾后悟出来”可靠得多。

4.4.2 状态快照也要持久化:全量一次,之后打补丁

世界状态的基线不能只活在内存里——进程重启后 resume(第 1 章的跨进程恢复)时,harness 必须知道“模型最后被告知的事实是什么”,否则只能把所有区段全量重发一遍。

做法是把快照写进持久化的事件流(rollout,第 10 章详述),而且同样遵循差分原则:

  • 第一次,写入一份全量快照
  • 之后每次变化,写入一份 JSON merge patch(RFC 7386 标准的“补丁”格式:变化的字段给新值,删除的字段标 null),恢复时逐个补丁 apply 回全量。

于是持久化流里,状态和对话共用一条只追加的日志:对话是 item,状态是快照与补丁,顺序交错,天然保持“模型被告知事实”的完整时序。

4.4.3 压缩后如何不丢“世界观”

第 10 章会讲压缩会物理替换掉大段历史,这意味着旧的状态片段可能从历史里消失——但补丁基线还在。这里有一个三方校验:

  1. 有精确基线快照?→ 按差分渲染;
  2. 基线在,但某个区段的片段必须留在历史里模型才看得到(比如权限说明)?→ 扫描现存历史,确认它的标签还在;被压缩掉了就当作“从未注入”,全量重发;
  3. 连基线都没有(更老版本的持久化文件、分叉出来的会话)?→ 用标签在历史里模糊匹配旧格式片段;匹配到了就把当前状态当“未知”处理——未知的安全策略是:当作没注入过,重发

宁可重复注入,也不让模型在缺失事实的情况下工作。这套“基线 + 历史标签”双保险,让世界状态在压缩、回滚、跨进程恢复之后总能自愈。

类比:对话历史像一个组织的会议纪要——逐字记录,谁也不能涂改;世界状态像办公室墙上的白板——写着当前的项目状态、约定、负责人。换人接手时(压缩),会议纪要可以摘要归档,但白板必须照着最新状态重新画一遍,而且只重画被擦掉的部分。


4.5 指令体系:模型到底在听谁的

上下文里的“指令性内容”比“事实性内容”更需要分层管理——因为它们来自不同的权威,冲突时必须有明确的优先级。Codex 的指令分四层:

graph TD
    L0["第 0 层:基础指令(顶层 instructions)<br/>模型档案自带模板;用户配置可整体覆盖<br/>——回答『你是谁、工具怎么用、行为准则』"]
    L1["第 1 层:全局用户指令<br/>~/.codex/AGENTS.md / 前端宿主下发<br/>——回答『我这个用户的通用偏好』"]
    L2["第 2 层:项目指令 AGENTS.md<br/>从项目根到当前目录逐层发现、拼接<br/>——回答『这个仓库的约定』"]
    L3["第 3 层:会话内动态指令<br/>世界状态片段、时间/预算提醒、hook 注入、停止 hook 续跑指令<br/>——回答『此刻你需要知道的事』"]
    L0 --> REQ["每次采样请求"]
    L1 --> REQ
    L2 --> REQ
    L3 --> REQ

第 0 层:基础指令。 第 3 章已经讲过:它不是全局常量,而是模型档案的一部分——不同模型有不同的调教模板(含个性变量、审批话术、多 agent 话术等),harness 只内置一份通用模板兜底。用户配置可以整体覆盖,系统会记录来源(“来自模型档案”还是“用户自定义”),切换模型时这个来源信息还会触发一条模型切换通知。

第 1 层:全局用户指令。 来自用户主目录下的全局 AGENTS.md(或前端宿主——比如 IDE 插件——通过接口直接下发的指令文本)。内容是跨项目的个人偏好:“我习惯用 pnpm 不用 npm”“回复尽量简洁”。它在拼接顺序上排在项目指令之前。

第 2 层:项目指令(AGENTS.md)。 这是信息量最大、也最有工程讲究的一层。发现过程如下:

  1. 找项目根:从当前工作目录向上走,直到遇到项目根标记(默认是 .git 目录,标记列表可配置);找不到就只认当前目录;
  2. 从上到下收集:从项目根到当前目录(含),每层目录找一个指令文件,按从根到叶的顺序拼接——外层目录的约定更通用,内层目录的约定更具体,越靠后的内容越贴近当前工作;
  3. 文件名优先级:每层优先看本地覆盖文件(AGENTS.override.md),其次才是标准的 AGENTS.md;还可以配置额外的候选文件名(用于兼容其他工具生态的约定文件);
  4. 多环境标注:一个会话可以连着多个执行环境(本地 + 远程,第 8 章),不同环境各自发现自己的 AGENTS.md,拼接时按环境分组标注(“以下指令针对 环境X,根目录是 …”);
  5. 硬性大小上限:所有项目指令加起来有字节预算(默认 32KB),超了从后往前截断——指令注入是有界的,绝不能让文档把窗口吃掉。

发现结果会按“环境选择”缓存:环境没变就不重新扫盘;环境切换或会话恢复时才重新加载。而加载后的内容不是直接写死在历史里——它被包装成世界状态的一个区段(4.4),所以用户在会话中途编辑 AGENTS.md,下一个步骤就能以“替换全部旧指令”的方式生效,不需要重启会话。

一个反直觉但刻意的设计:AGENTS.md 是以“用户”角色注入的(外层包着 # AGENTS.md instructions ... <INSTRUCTIONS>...</INSTRUCTIONS> 标签)。为什么不用开发者角色?因为模型对“用户说的话”注意力权重天然更高——项目约定是希望模型严格遵守的内容,用用户角色表达遵从度更好;而 harness 自己的运维信息(权限、预算、时间)用开发者角色,明确“这是系统旁白,不是任务要求”。角色本身也是一种注意力编程。

第 3 层:会话内动态指令。 就是 4.3/4.4 的片段体系:世界状态差分、时间提醒(按可配置的间隔,且只在“用户发言或工具结果之后”的边界投放,避免在模型连续工作时用同一条提醒刷屏)、token 预算提醒、rollout 预算提醒(整棵 agent 树共享的加权 token 预算)、中断标记(第 1 章)、hook 注入的额外上下文、停止 hook 要求续跑时注入的“你还有事没做完”指令。此外前端宿主还可以下发独占的开发者指令(比如自动审查子 agent 拿到的审查策略,必须独立成条、便于审计)。


4.6 历史的累积、清洗与计量

再回到对话历史这一侧。它的数据结构简单得出奇:一个按时间顺序排列的 item 向量,包在引用计数指针里——克隆历史是廉价的(共享底层数据,只有修改时才复制),轮次循环、压缩逻辑、扩展读快照都可以各拿一份“自己的历史”而不付深拷贝代价。历史有一个版本号,每当历史被重写(压缩、回滚)就加一;只追加不改变版本号。

4.6.1 入库即截断:工具结果的“中间省略”

历史增长的最大来源是工具输出。一条 npm test 可能吐出五万行。harness 在结果入库的那一刻就按模型档案上的截断策略(按字节或按 token,模型各有上限)处理掉,而不是等发请求时才裁。截断方式是保留头尾、挖空中间

  • 头部保留:命令启动阶段的信息、报错往往在最前面;
  • 尾部保留:最后的错误摘要、退出状态往往在最后面;
  • 中间用省略标记替代,并注明原始规模(“输出已截断,原始 token 数:…”)。

为什么是中间而不是末尾?因为对话是流式增长的,模型永远在历史的尾部工作——最近的信息最有价值;而开头承载着“这个命令在干什么”的语境。中间的大段重复日志(编译进度、逐条测试用例)信息量最低。截断预算还会乘一个 1.2 的序列化系数,给 JSON 包装留出余量。

4.6.2 发请求前的规范化:三对不变量

历史入库后并不直接等于请求内容。每次采样前,历史要过一道规范化(normalize)

  1. 每个工具调用必须有对应结果,每个结果必须有对应调用。 中断、重试可能留下“调了工具但结果还没回灌”或“结果成了孤儿”的半成品——规范化会补齐占位或移除孤儿,保证模型永远看不到悬空的工具调用(这对某些模型是硬错误);
  2. 按目标模型能力剥离不支持的模态。 当前模型不吃图片?历史里的图片内容剥掉;不吃音频?音频剥掉。同一份历史要能安全地发给任何模型(第 3 章的兜底档案原则在这里闭环:不知道模型会什么,就只给文本);
  3. 系统角色消息不外发。 内部标记为 system 的 item 不进入请求。

4.6.3 token 计量:服务端为权威,本地做估算

窗口管理的前提是知道“现在用了多少 token”。精确数字只有服务端有(响应结束时上报真实用量),但两次响应之间新追加的 item(刚回灌的工具结果、刚注入的片段)服务端还没见过。harness 的办法是双轨制

  • 服务端上报的用量覆盖“上一个模型产出 item 之前”的全部内容,这是权威值;
  • 之后本地追加的 item,用字节启发式估算(约 4 字节/token)补上;
  • 特殊内容特殊折算:加密思维链按密文长度的 3/4 再减常数估算(密文比明文膨胀);图片按视觉补丁数折算(而不是按 base64 的字节数——一张图片编码后几兆字节,实际只占一两千 token);音频按时长折算。

估算只用于“该不该压缩、还剩多少”这类触发判断;账单和精确统计永远以服务端数字为准。估算刻意保守、从粗,因为它的成本极低(序列化长度即可),而精确分词器既慢又需要随模型更新。


4.7 上下文窗口:三道防线

现在面对那个终极问题:历史滚到窗口装不下了,怎么办?

先算清楚两条线(都来自模型档案,第 3 章):

  • 有效窗口 ≈ 模型窗口 × 95%。要给系统指令、工具定义和模型自己的输出预留头部空间,不能把窗口算到顶;
  • 自动压缩阈值 ≈ 窗口的 90%(可配置调低,不能调高)。到这条线就该动手了——必须在真正撞墙之前留出“压缩本身还需要空间”的余量。

计量口径还有一个讲究:可以选择按全量计数,也可以只按窗口起点之后的增量计数(prefill 基线优先采用服务端实测值,本地估算兜底)。后者让“压缩后保留的前缀”不占用新窗口的预算,判断更贴近真实剩余空间。

Codex 没有把“压缩”当作唯一手段,而是布置了三道纵深防线:

flowchart TD
    A["历史持续增长"] --> B{"剩余 ≤ 提醒阈值?"}
    B -->|"是"| C["防线一:注入预算提醒<br/>(每个窗口只提醒一次)<br/>模型可自行收尾 / 主动开新窗口"]
    B -->|"否"| A
    C --> D{"模型调用<br/>new_context 工具?"}
    D -->|"是"| E["防线二:模型自助滚动窗口"]
    D -->|"否"| F{"到自动压缩阈值<br/>或撞窗口硬上限?"}
    F -->|"是"| G["防线三:自动压缩<br/>(轮次前 / 轮次中)"]
    E --> H["新窗口:全量重建世界状态"]
    G --> H
    H --> A

防线一:提醒。 剩余 token 跌破阈值时,harness 以开发者角色注入一条提醒(“本上下文窗口还剩 N token”,每个压缩窗口只投一次,防止刷屏),模型可以据此自己加快收尾、少读大文件。模型还可以主动调用两个工具自助查询和处理:get_context_remaining(查询剩余 token)和 new_context(主动开一个新上下文窗口——工具描述里特别声明:“不清理、不重置、不影响任何环境状态”)。这句话点破了上下文管理的世界观:环境状态在机器上(文件、进程、权限),上下文只是模型的工作记忆;换一本笔记本,世界并没有变。

防线二:模型自助滚动窗口。 模型判断对话告一段落时,可以主动调用 new_context。harness 不做摘要,直接开新窗口:历史替换为全量重建的世界状态(当前环境、权限、指令……重新注入一遍)加上需要保留的少量前端开发者消息,窗口编号加一。这是最轻量的“翻篇”。

防线三:自动压缩。 到达阈值(或模型服务端直接返回“上下文超长”错误)时,harness 强制压缩。触发时机有两个:轮次开始前检查(上一轮结束时已经超限);轮次中每次采样+工具回灌后检查(长工具输出把窗口撑爆)。触发原因也不止“超长”一种:

触发原因 场景
上下文到限 token 用量越过阈值或硬上限
压缩兼容哈希变化 切换到与旧模型“压缩格式不兼容”的新模型(模型档案声明兼容哈希),先用旧模型压缩再切换
模型降级 切到窗口更小的模型,当前历史在新模型下放不下,提前压缩
用户手动 显式发起压缩(/compact),作为独立轮次运行

压缩有三种实现,按能力自动选择:

  1. 窗口滚动(token budget 模式):不做摘要,逻辑同防线二——新窗口 + 全量重建世界状态。最省、最快,代价是模型失去对话细节(但事实都在世界状态和文件系统里);
  2. 远端压缩:模型服务端提供专门的压缩端点时,把历史发给服务端做摘要,服务端返回压缩后的历史(近期消息在约 64k token 预算内原样保留,较早的内容被摘要替代)。省客户端算力,压缩质量由服务端统一迭代;
  3. 本地摘要压缩(兜底):服务端不支持时,harness 自己跑一次模型采样完成摘要。提示词写得很直白——“你在做一次上下文检查点压缩,为另一个将要接手任务的 LLM 写一份交接摘要”,要求包含:当前进展与关键决策、重要约束与用户偏好、待办事项、继续工作所需的关键数据。

本地压缩产出的替换历史由三部分组成:近期的真实用户消息(按 20k token 预算从新到旧保留,超出部分整条不选)、一条以固定前缀开头的摘要消息(“另一个模型已经开始解决这个问题并留下了思考摘要……在此基础上继续,避免重复劳动”),以及重建的环境上下文。这里有一个非常细腻的摆放规则:轮次中压缩时,环境上下文插在最后一条真实用户消息之前,摘要保持在历史最末尾——因为模型被训练为“压缩后看到的最后一条是摘要”;而轮次前/手动压缩时不插环境,改为清空状态基线,让下一个常规轮次自己全量重建。

压缩流程还有几个值得驻足的工程细节:

  • 压缩本身也可能超长。 历史太满时,连“请总结这段历史”的请求都发不出去。此时 harness 从最旧的 item 开始逐条丢弃后重试(从头删既保住缓存前缀,又保住最近的对话),直到压缩请求能发出去;
  • 压缩前后有钩子。 外部 hook 可以在压缩前/后投反对票中止压缩(第 11 章),压缩和普通轮次一样可被中断;
  • 压缩是可见的历史事件。 历史里会留下压缩标记 item,UI 上显示为一次压缩记录;压缩后还会给用户一条忠告式警告:“长线程和多次压缩会降低模型准确性,尽量开新线程”;
  • 窗口有身份。 每个压缩窗口有编号和 UUID(首个窗口、上一个窗口、当前窗口),注入到上下文里。模型由此知道“我经历过几次压缩、现在处在哪个窗口”,这些 ID 同时用于遥测和服务端缓存隔离;
  • 旧模型压缩失败有兜底。 按理应在切换模型前用旧模型压缩,但旧模型已不可用时,自动用当前模型重试一次。
sequenceDiagram
    participant T as 轮次循环
    participant H as 历史
    participant M as 模型
    T->>H: 采样后检查:超限?
    H-->>T: 已到压缩阈值
    T->>M: 压缩请求(完整历史 + 交接摘要提示词)
    Note over M: 若超长:从最旧 item 逐条丢弃重试
    M-->>T: 交接摘要
    T->>H: 替换历史:近期用户消息 + 摘要<br/>+ 重建的世界状态
    Note over H: 窗口编号 +1,基线重置<br/>全量快照持久化
    T->>M: 用压缩后的历史继续采样

注意整个过程中对话事实没有丢:磁盘上的完整事件流(rollout)依然保留着全部原始 item,压缩替换的只是“发给模型的工作历史”。用户随时可以回看、可以从旧点分叉(fork,第 1 章);模型的工作记忆变薄了,但会话的完整档案还在。


4.8 小结:上下文管理的五条设计原则

  1. 对话与事实分离。 历史是只追加的流水账(谁在什么时候说了什么),世界状态是此刻的事实快照(目录、权限、指令、模式)。事实的更新走差分注入并显式声明替换语义,绝不让模型在矛盾的旧快照里猜哪条有效。环境状态在机器上,上下文只是视图——这是“开新窗口不丢世界”的根本前提。

  2. 一切注入皆片段。 每个注入上下文的内容都是带角色、带标签、有大小上限的结构体;标签同时服务模型(结构化信号)和 harness(自我识别);片段可合并、可替换、可在压缩后重建,对扩展读历史则隐形。扩展和 hook 注入也必须走同一套接口,没有裸字符串特权。

  3. 有界是硬约束。 项目指令 32KB 预算截断、工具输出入库即头尾截断、任何片段都有 token 上限、窗口按 95% 折算、阈值按 90% 预留——上下文里的每一类内容都有明确的尺寸天花板,且默认值宁保守勿冒进。

  4. 事实多版本共存:权威值与估算值分开。 token 计量以服务端上报为权威、本地字节估算补增量;状态基线以持久化快照为准、历史标签扫描兜底;指令来源(模型档案/用户自定义)全程留痕。估算可以粗,决策依据必须可追溯。

  5. 超长是渐进处理的,不是突然死亡。 提醒 → 模型自助开窗口 → 自动压缩(滚动 / 远端摘要 / 本地摘要三级实现)→ 压缩中超长则逐条丢旧重试,每一级都比上一级多付代价、少留情面;而所有重写都只动“工作历史”,完整事件流永不丢失,可回看、可分叉、可恢复。

留给读者思考的几个问题

  • 世界状态用“差分片段 + 替换声明”更新,而不是靠模型自己从历史里推断最新状态——如果省略替换声明、只追加新值,模型在长历史中会犯什么错?这和第 2 章“事件陈述事实”的原则有什么关系?
  • AGENTS.md 刻意以“用户”角色注入,而环境/权限信息以“开发者”角色注入——角色选择如何被当作一种“注意力编程”手段?什么内容适合哪种角色?
  • 工具输出截断选择“留头留尾挖中间”,而压缩选择“留近期消息 + 摘要替代早期”——两者保留信息的策略为什么不同?(提示:工具结果的消费场景和成段对话的消费场景有何差异?)
  • 本地压缩把摘要放在历史最末尾、环境上下文插在倒数第二条之前——为什么模型“被训练为最后看到摘要”这件事能成立?如果把摘要放在开头会怎样?(→ 第 5 章 LOOP)
  • 状态快照持久化用“全量一次 + 后续 merge patch”,这和第 3 章 WebSocket 增量请求的“严格前缀校验”面临的是同一类什么风险?补丁 apply 出错时为什么安全策略是“重发”而不是“忽略”?(→ 第 10 章)

下一章我们进入 LOOP:把前面所有零件组装起来——agentic 主循环每一圈的具体步骤、模型回复如何驱动分支、以及循环本身在哪些地方被刻意设计成“不自由”。

分类:Agent Harness标签:#agent #harness #codex

第三章 模型接入与推理控制:把"黑盒引擎"变成可调、可换、可续的服务

第二章我们把内核看作一个协议网关:外侧对着前端,内侧对着模型。 本章我们走到网关的内侧,仔细看看“对接一个模型”到底意味着什么。 模型不是一个调用一下就返回的函数——它是一个遥远的、有状态幻觉的、会限流会掉线的远程服务;而且市面上同时存在几十个能力各异的模型,每个月还在出新的。 harness 必须回答三类问题:怎么连上(接入)、怎么让它按我想要的方式思考(推理控制)、它出状况时怎么让轮次不断(容错)。


3.1 为什么“填个 API key”远远不够

新手对模型接入的想象是:一个 URL、一把密钥、一个模型名,发请求收回复,完事。

真实场景里,harness 面对的是这样一团乱麻:

  • 模型各不相同。 有的模型支持“思考强度”调节,有的不支持;有的能输出思维链摘要,有的不能;有的上下文窗口 20 万 token,有的 100 万;有的接受图片和音频输入,有的只吃文本;工具调用的并行能力、结构化输出的严格程度、补丁工具的格式……全都因模型而异。
  • 服务商各不相同。 OpenAI 官方、ChatGPT 后端、Amazon Bedrock、本地的 Ollama/LM Studio,甚至用户自己搭的兼容端点——认证方式不同(API key、登录令牌、命令行动态取 token、云厂商签名)、传输能力不同(支不支持 WebSocket)、限流策略不同。
  • 服务随时会出状况。 限流(429)、过载(5xx)、网络中断、流传到一半断掉、凭证过期、服务端安全审查把请求改道到另一个模型、模型退役下架……
  • 同一次会话里模型可能变。 用户中途切换模型、计划模式用另一档模型、子 agent 用更便宜的模型、压缩历史时用专门的压缩模型。

如果让上层的轮次循环(第 1 章)直接面对这些差异,代码会变成一张撒满 if 模型名 == ... 的破网。Codex 的做法是把它们收敛成两层抽象:

  • Provider(服务商):回答“请求往哪发、用什么认证、走什么传输、失败重试几次”——它是线路
  • Model(模型档案):回答“这个模型会什么、支持哪些旋钮、上下文多大”——它是员工档案

轮次循环只认这两层抽象,既不知道也不关心对面是 OpenAI 还是用户自己搭的端点。

graph LR
    subgraph 轮次["轮次循环(只认抽象)"]
        TURN["采样请求:上下文 + 工具 + 推理参数"]
    end
    subgraph 接入层
        MM["模型目录<br/>模型档案(能力卡片)"]
        MC["模型客户端<br/>服务商(线路)"]
    end
    subgraph 远端["模型世界"]
        OAI["OpenAI / ChatGPT 后端"]
        BR["Bedrock"]
        LOCAL["Ollama / LM Studio / 自定义端点"]
    end
    TURN -->|"查能力、冻结快照"| MM
    TURN -->|"发流式请求"| MC
    MC --> OAI
    MC --> BR
    MC --> LOCAL
    MM -.->|"目录本身也来自远端"| OAI

注意最后那条虚线:模型档案本身也是从服务端拉取的。模型目录不是写死在代码里的常量表,而是一个带缓存、带热更新的远程数据集——这是理解本章一切设计的钥匙。


3.2 Provider:一条“线路”的全部参数

Provider 描述的是“怎么到达模型服务”。一个内置或用户自定义的 provider,包含这些信息:

类别 字段 说明
位置 base URL、查询参数 请求发到哪;ChatGPT 登录态和 API key 走不同默认地址
认证 环境变量 key、静态 bearer、命令行取 token、云厂商签名 命令行动态取令牌支持定时刷新;云厂商签名有自己的刷新命令
传输 线协议(只保留 Responses 流)、是否支持 WebSocket、是否支持独立联网搜索端点 旧的 chat 补全线协议已被移除,配置到会直接报错并给出迁移指引
韧性 请求重试次数(默认 4)、流重连次数(默认 5)、流空闲超时(默认 5 分钟)、WebSocket 连接超时(15 秒) 用户可调,但有硬上限(100 次)防止配出无限重试
杂项 附加 HTTP 头(静态值或从环境变量取值)、请求压缩开关 组织 ID、项目 ID 等通过环境变量头注入

内置的 provider 有五类:OpenAI 官方、Amazon Bedrock(两个变体)、Ollama、LM Studio。用户还可以在配置文件里自定义任意 OpenAI 兼容端点。设计上有一个明确的立场:官方不替第三方服务商做背书,内置列表只收录第一方和开源本地端点,其余由用户自行登记。自定义 provider 与内置条目合并时遵循“只扩展、不覆盖”的原则(Bedrock 条目例外,只允许改端点和认证等少数字段)。

认证方式值得多说一句,因为它体现了“harness 不持有秘密”的安全取向:最推荐的是把密钥放在环境变量里,provider 配置只写变量名;静态令牌字段被明确标注为“不推荐”;企业场景下 token 由一个外部命令产出(还能配合刷新命令自动续期),harness 每次请求前调用命令取最新值,密钥永远不落配置文件。

类比:provider 像手机里的“运营商设置”——用哪个基站、什么频段、信号差时重拨几次。你平时完全感知不到它,但出国旅行(换服务商)时全靠它。


3.3 Model:模型不是一个名字,而是一张“能力卡片”

轮次循环真正依赖的,是模型档案里那张内容丰富的能力卡片。每当要发采样请求,harness 都要先回答:这个模型支持什么?卡片上的信息大致分四组:

第一组:思考与输出控制。

  • 支持哪些推理强度档位(none/minimal/low/medium/high/xhigh/max……),默认档是多少;
  • 是否支持思维链摘要参数、默认摘要详细度;
  • 是否支持输出详略(verbosity)控制、默认详略;
  • 支持哪些服务等级(快速通道/弹性通道,见 3.4)。

第二组:上下文与窗口。

  • 上下文窗口大小、允许配置覆盖的最大值;
  • “有效窗口比例”(默认 95%,要给系统指令、工具定义和模型输出预留头部空间);
  • 自动压缩的 token 阈值(缺省按窗口的 90% 派生,且配置值不能超过这个派生值);
  • 工具输出的截断策略(按字节还是按 token、上限多少)——模型读不下无限长的命令输出。

第三组:功能形态。

  • 输入模态:文本、图片、音频;
  • shell 工具的形态、补丁工具是否为自由格式(第 2 章讲过,自由格式工具的参数才能流式预览);
  • 联网搜索工具的形态、是否支持工具搜索、代码模式(code mode)、多 agent 协议版本;
  • 是否启用一种叫 “Responses Lite” 的精简传输格式(3.5 节展开);
  • 实验性工具清单、技能/插件使用说明是否注入。

第四组:文案与身份。

  • 模型自己的基础指令模板(包括个性变量、审批话术、权限说明、多 agent 话术、token 预算提醒文案等);
  • 压缩兼容标识(comp hash,压缩历史时判断模型间是否兼容);
  • 升级建议与退役时间、新模型可用性提示。

第四组尤其反直觉:连系统提示词都是模型档案的一部分。不同模型的行为调校对指令有不同要求,指令跟着模型走,而不是全局一份。harness 自己只内置一份通用基础指令,作为未知模型的兜底。

3.3.1 目录从哪来:四层来源,逐级降级

模型档案不是编译期常量,而是运行时拼出来的,共四层来源:

flowchart TD
    A["① 内置目录 models.json<br/>编译进二进制,开箱即用"] --> B{"需要刷新?"}
    B -->|"在线策略"| C["② 远端 /models 端点<br/>ChatGPT 账号登录且目录有效 → 远端为权威<br/>否则按模型名合并进内置目录"]
    B -->|"离线/缓存策略"| D["③ 磁盘缓存 models_cache.json<br/>5 分钟 TTL,按客户端版本失效"]
    C --> D
    D --> E["④ 兜底档案<br/>未知模型名:保守能力 + 通用指令<br/>打上 fallback 标记"]
    C --> E

几个关键设计:

  • 远端目录是热数据。 启动时和运行中都会按策略刷新;服务端还能通过响应头下发一个 ETag,harness 发现 ETag 变了就后台拉取新目录——新模型上线、旧模型退役,不需要用户升级客户端
  • 缓存保证离线可用。 磁盘缓存带 5 分钟有效期和客户端版本校验(新版客户端可能需要新字段,旧缓存直接作废);网络失败时静默降级到缓存或内置目录,模型发现失败永远不会阻塞用户开工。
  • 模型名匹配是“前缀匹配”。 请求的模型名是 gpt-5.6-codex-20260801 这类带日期后缀的 slug,目录里登记的是 gpt-5.6-codex 这类家族名,按最长前缀匹配;带命名空间的名字(如 custom/gpt-5.3-codex)还会剥掉一段前缀再匹配。
  • 兜底档案宁保守勿冒进。 完全未知的模型名拿到一份“最低能力”卡片:272k 窗口、通用指令、所有高级开关关闭,并打上兜底标记。这个标记会让一轮只警告一次(“未找到模型元数据,使用兜底配置,可能影响表现”),同时关闭依赖元数据准确性的功能(如自动审查、子 agent 的严格模型校验)。原则很清楚:不知道模型会什么,就假设它什么都不擅长。
  • 用户配置可以覆盖,但有钳制。 手动指定的上下文窗口不能超过档案声明的最大值;工具输出截断、自动压缩阈值、基础指令、个性开关都可以在配置层调整。

3.3.2 一张卡片,全会话共享

模型目录由进程级的线程管理器持有(第 1 章说过,认证、目录这类重资源是跨线程共享的)。但每次轮次开始时会重新解析一次模型档案并冻结进轮次快照——这保证了两件事:轮次中途目录热更新不会让同一个轮次“前一半用旧档案、后一半用新档案”;而新轮次自然吃到最新目录。这与第 1 章的步骤快照、第 2 章的“事件即永恒”是同一种思想:边界处冻结,变化只发生在边界之间。


3.4 推理控制的四个旋钮

打开一次采样请求的请求体,能影响模型“怎么想、怎么答”的旋钮有四个。它们全部遵循同一条规则:值从哪来有优先级,发不发要看能力卡片。

3.4.1 推理强度(reasoning effort):思考的“油门”

推理强度控制模型在回答前投入多少内部思考:档位从 none、minimal、low、medium、high、xhigh 到 max,逐级递增。档位越高,思考越充分(复杂推理、多步规划更可靠),但延迟越高、消耗的推理 token 越多——而推理 token 同样计入上下文窗口和账单。

几个耐人寻味的细节:

  • 档位是开放集合,不是封闭枚举。 反序列化时遇到不认识的新档名字符串不会报错,而是存为“自定义值”原样透传。新模型推出新档位时,旧客户端不需要升级就能用——这与第 2 章“协议只增不废”的演化哲学一脉相承。
  • 有一个内部档位 ultra,上线前会被翻译成 max。 ultra 在协议线上不存在,它是 harness 内部的信号:除了“最高思考强度”,还顺带触发“主动式多 agent 编排”模式(第 7 章展开)。对模型服务端来说它就是 max,对 harness 自己来说它是一个行为开关。同一个旋钮,对外是推理参数,对内是编排信号。
  • 有效值有三级回退。 用户显式指定 > 协作模式指定(计划模式可以用不同档位)> 模型卡片的默认档;都没有就不下发,让服务端用自己的默认。
  • 换模型时档位会重映射。 从高档位模型切到只支持 low/medium 的模型时,当前档位不在新模型的支持列表里,harness 不会发一个非法值,而是回落到新模型支持档位的中位数(而不是 silently 用最高档或最低档——中位数是“不算冒险也不算浪费”的折衷)。

3.4.2 思维链摘要(reasoning summary):愿意给人看多少“思路”

模型的思考分两层(第 2 章见过):加密的原始思维链(服务端不希望明文离开,但允许加密回传以保持多轮一致)和可读的思考摘要。摘要参数控制后者:auto / concise / detailed / none。

摘要和原始思维链是两套独立机制,别混淆:

  • 加密思维链通过请求里的 include 字段始终索取reasoning.encrypted_content),harness 只搬运、永远看不到明文,下一轮随上下文回传,保证模型“记得自己想过什么”;
  • 摘要是否下发,要看能力卡片上“这个模型支持摘要参数吗”——不支持就整个字段省略,绝不能把参数发给不认识它的模型。

摘要还有一个传输优化:开启“并发摘要”特性时,harness 会在流选项里声明 sequential_cutoff 模式,让服务端按段落截止点分批投递摘要,而不是把整段思考憋到最后——配合第 2 章的“段落”事件,UI 能更早渲染出思考过程。

3.4.3 输出详略(verbosity):回答的“篇幅”

verbosity(low/medium/high)控制的是最终答复的详尽程度,不是思考深度——它和推理强度是正交的两个维度:一个模型可以“想得很深、答得很简短”。这个旋钮同样只有能力卡片声明支持时才发送;用户硬配了一个不支持的模型,harness 会打一条警告日志然后忽略,而不是让请求报错。

3.4.4 服务等级(service tier):走哪条“车道”

服务等级是计费/路由层面的选择:priority(快速通道,优先级高、单价高)和 flex(弹性通道,容忍排队换低价)。请求里只会出现模型卡片明确支持的等级,不支持的等级被过滤掉;还有一个特殊的“default”哨兵值,表达“用户显式选择不加等级”,以区别于“没配置、用模型默认”。

graph TD
    subgraph 请求组装
        U["用户/配置/协作模式<br/>指定的期望值"]
        U --> G{"能力卡片<br/>支持吗?"}
        G -->|"支持"| SEND["写入请求体"]
        G -->|"不支持"| DROP["省略字段 / 回落到默认 / 警告"]
    end
    subgraph 四个旋钮
        E["推理强度:思考多深"]
        R["思维链摘要:思路展示多少"]
        V["输出详略:回答多长"]
        T["服务等级:走哪条车道"]
    end
    SEND -.-> E
    SEND -.-> R
    SEND -.-> V
    SEND -.-> T

3.4.5 两个“故意不做”的控制

除了“发什么”,“不发什么”同样说明设计取向:

  • 工具选择永远是 auto 请求体里 tool_choice 恒为 auto——harness 从不命令模型“这一步必须调某个工具”。强制工具调用是一种脆弱的控制:模型为了完成命令会在条件不满足时硬调。harness 选择把“该做什么”完全交给模型判断,把确定性放在结构化输出上:需要模型按固定格式产出时(比如审查结论),用 text.format 挂一个 JSON Schema(还带严格模式开关),模型可以自由思考,但最终答案必须符合 schema。自由给过程,约束给结果。
  • 不设置最大输出长度。 让模型自己决定何时说完(第 2 章的 end_turn 标志),而不是用一个 token 上限把回答拦腰截断。

3.5 Responses Lite:同一语义,两种字节形态

能力卡片里有一个开关特别能代表“模型差异如何被吸收”:use_responses_lite

常规请求里,系统指令放在顶层 instructions 字段、工具清单放在顶层 tools 字段。而开启 Lite 的模型,harness 会把两者改造成输入历史开头的两条 developer 角色条目(一条“附加工具”条目 + 一条指令消息),顶层字段留空;同时:

  • 关闭并行工具调用(该传输格式下不支持);
  • 推理上下文显式声明为“全部轮次”(常规路径下省略此字段,用服务端默认的“当前轮次”);
  • 剥离图片的清晰度细节参数;
  • 工具名按命名空间组织。

为什么要搞两套?因为后端模型的上下文缓存与计费机制对“指令/工具放在哪”有不同的优化路径——某些模型把一切都当作对话条目处理时缓存命中率更高。这纯粹是线上字节布局的差异,语义完全等价:轮次循环组装的还是同一个“上下文 + 工具 + 指令”三元组,差异被封装在请求构建的最后一公里。

这件事的一般教训是:当对接的模型足够多时,“请求长什么样”不应该由调用方决定,而应该由目标模型的能力卡片决定。 轮次循环不需要知道 Lite 的存在;新增一种传输格式,只是在能力卡片上加一个开关、在请求构建处加一个分支。

对第三方 provider 也有类似的“线路卫生”处理:发给非 OpenAI 端点前,harness 会剥除条目上的内部透传元数据和加密函数参数——这些第一方协议里的私房字段,出了 OpenAI 的边界就不该存在。


3.6 让重发历史变便宜:缓存键与增量请求

Agent 循环的每个步骤都要把完整历史重新发给模型(第 1 章)。历史滚到几十轮后,请求体里 95% 以上是和上一次相同的内容。harness 用两套机制压低这笔重复成本,恰好对应第 2 章的两种传输。

3.6.1 SSE:无状态请求 + 缓存键

HTTP SSE 路径下,harness 刻意把请求做成无状态的:store 标志恒为 false——不要求服务端持久化任何会话,每个请求自带全部上下文,自足可解释。

那服务端缓存靠什么命中?靠请求里的 prompt cache key:它默认就是会话 ID,同一会话的所有请求带着同一个键。服务端据此把“这个键对应的前缀”放进 prompt cache,重复的前缀只收缓存价。

这里能看出一个与前两章的精妙呼应:

  • 第 1 章说“历史只追加、不重写”、插话只在步骤边界注入——这保证了请求的前缀天然稳定:第 N 次请求的输入 = 第 N-1 次的输入 + 尾部新增。如果历史会被改写、重排,缓存键再稳定也命中不了。
  • 缓存键按会话隔离,而会话是一棵 agent 树共享的(第 1 章的 session ID),子 agent 与父 agent 的请求也能共享缓存前缀。

3.6.2 WebSocket:链式增量 + 严格校验

WebSocket 路径更进一步:同一条长连接上,后续请求可以只发新增的条目,用 previous_response_id 把自己链到上一个响应上,服务端凭连接上保存的状态拼出完整上下文。轮次开始前还会发一个 generate=false 的“空预热”请求——不产生任何输出,只为把连接和缓存预热好;会话启动时这个预热就已经在后台 best-effort 进行了(带超时预算,预热不成不影响开工)。

增量发送能省下可观的上行带宽,但它有一个致命风险:如果“我以为服务端记得的前缀”和“服务端实际记得的”不一致,模型就是在错误的上下文上作答。 所以 harness 对“能不能发增量”做了极其严格的校验:

flowchart TD
    A["准备第 N 次请求"] --> B{"非输入字段与上一次<br/>完全一致?<br/>(模型/指令/工具/推理参数/<br/>服务等级/缓存键/text 控制……)"}
    B -->|"任一不同"| F["全量重发"]
    B -->|"一致"| C{"输入是上次输入 +<br/>上次响应产出条目的<br/>严格前缀扩展?"}
    C -->|"不是"| F
    C -->|"是"| D["只发送尾部新增条目<br/>+ previous_response_id"]
    D --> E{"服务端有响应 ID?"}
    E -->|"没有"| F
    E -->|"有"| G["增量请求成立"]

两个校验缺一不可:

  • 非输入字段逐一比对,而且比对代码用的是“穷尽解构”写法——请求体未来新增任何字段,编译器都会强迫开发者显式决定“这个字段变化时是否还允许增量”,不可能悄悄漏判。推理强度、工具集、服务等级任何一个变化,都会改变“这份上下文的含义”,必须全量重发。
  • 输入必须是严格前缀扩展:上一次的输入条目、加上服务端上次响应产出的条目(模型说的话、工具调用也是上下文的一部分),构成基线;当前输入只能在基线上尾部追加。逐条比对不一致就全量重发。

再叠加第 2 章讲的粘性路由令牌(同一轮次钉在同一后端实例上,且严禁跨轮次复用),三层机制共同保证:增量优化只在“服务端状态与本地认知严格一致”时发生,错一点就退回无状态全量。性能收益拿满,正确性不赌运气。


3.7 故障世界:限流、掉线、改道与凭证过期

模型服务是远程的,故障是常态而非例外。第 2 章已经从协议角度讲过“流上的错误是 Err、可重试错误触发重连”,本节从推理控制的角度补齐:哪些错误值得重试、哪些必须立刻认输、限流怎么计量、模型被调包怎么办。

3.7.1 错误的四象限

轮次级的错误分类决定了每种故障的命运:

错误 性质 harness 的反应
上下文超长 致命 不重试;标记窗口已满,交给压缩逻辑(第 4 章)
用量/额度耗尽(硬限额) 致命 不重试;把限流快照带给 UI,错误文案精确区分“你的额度用尽”“工作区额度用尽”“消费上限”等场景,告诉用户该做什么
请求非法 致命 不重试;这是 bug 或不支持的用法,重试一百次也是错
安全策略拦截 致命 不重试;转错误事件
软限流(429 拥塞)、流中断、网络抖动、连接失败、超时、瞬时服务端错误 可重试 退避后重发;重试用的是持久化历史重新组装请求,已完成的工作不丢(第 2 章)

注意“限流”被劈成了两半:瞬时拥塞(你太快了,等会儿再来)是可重试的,服务端还会通过 Retry-After 告诉客户端等多久——harness 优先采用这个值,没有才用自己的指数退避(200ms 起步、翻倍、±10% 随机抖动防重试风暴);额度耗尽(你没钱了/没配额了)是致命的,重试毫无意义,需要的是可操作的提示。同样是 429,语义天差地别。

3.7.2 两级重试与传输降级

重试分两级,数字都可以按 provider 调整:

  • 请求级(连接还没建立就失败):默认 4 次,退避重试;5xx 和传输错误重试,软 429 不在这一级处理;
  • 流级(流传到一半断了):默认 5 次;前端收到咨询性的“重连中…… 2/5”事件(发布版会刻意压低第一次 WebSocket 重连的提示音量,避免瞬断刷屏)。

两级都失败后还有一张底牌:WebSocket → HTTP SSE 降级。一旦降级,本会话剩余时间都 stick 在 HTTP 上(这是会话级的粘性决策,反复横跳没有意义),并明确告知用户“已从 WebSocket 回退到 HTTPS 传输”。握手阶段收到“需要升级协议”之外的拒绝也会立即触发降级。此外有一个特性开关:纯网络连接失败时(非内部任务、非 Bedrock),可以无限期重连,退避从 5 秒封顶到 60 秒,提示“重连中……等待网络”——笔记本合盖、地铁断网这类场景,轮次可以一直等到网络回来。

3.7.3 凭证过期:自己先治,治不好再报错

还有一类高频故障:令牌过期(401)。它走专门的认证恢复路径:识别出可恢复的认证错误后,harness 调用认证管理器刷新凭证(命令行取 token 的 provider 此刻重新执行取 token 命令),然后用新凭证重试原请求;刷新失败才认输。整个过程对轮次透明,用户最多感知到一次短暂停顿。

3.7.4 模型改道与安全缓冲:服务端“偷偷”做的事

服务端并不总是按你点的菜上菜:

  • 安全路由改道。 高风险请求可能被后端路由到另一个模型处理。响应头里会带回“实际服务的模型名”,harness 比对发现与请求不符,就发一个警告事件——每轮最多一次(用原子标志位去重,防止刷屏)。它只是警告不是错误:轮次继续,用户知情即可。
  • 安全缓冲。 服务端安全审查期间,输出可能被缓冲延迟。流里会带上缓冲原因、适用场景,以及“要不要换个更快的模型重试”的建议;UI 显示“审查中”。
  • 限流快照作为旁路数据。 每次响应头里的配额信息(主/副两个时间窗口的剩余量、积分余额)被解析成快照,攒到响应结束随用量事件一起发给前端——用户在状态栏看到“本周额度还剩 30%“靠的就是它。软限流时它是提示,硬限额时它是致命错误的配图。
  • 目录热更新。 如 3.3 所述,响应头里的目录 ETag 会触发后台刷新模型目录。

3.7.5 模型不只陪用户聊天

最后一个容易被忽略的事实:模型客户端是 harness 内部的公共设施。除了主对话,它还服务于:

  • 历史压缩:专门的压缩端点(也可复用普通采样),且压缩时如果“历史里记录的旧模型”已经不可用,会自动用当前模型重试一次(压缩模型兜底);
  • 记忆巩固:后台把长期记忆总结成摘要的内部任务;
  • 自动审查:高风险操作前的模型审查,还可以指定专用的审查模型。

这些内部调用复用同一套客户端、同一套重试与认证恢复逻辑,只是推理参数(比如摘要关闭、强度调低)和遥测标记不同。对话、压缩、审查、记忆,在 harness 眼里都是“带着不同参数的一次采样”。


3.8 模型的选择与切换

把上面的零件串起来,看一次会话中模型决策的完整时间线:

sequenceDiagram
    participant U as 用户/前端
    participant S as 会话
    participant MM as 模型目录
    participant M as 模型服务

    M-->>MM: 启动/ETag 变化:拉取 /models
    U->>S: 开轮次(可携带模型/档位覆盖)
    S->>MM: 按模型名解析能力卡片(前缀匹配,未知则兜底)
    MM-->>S: 冻结模型档案进轮次快照
    S->>M: 采样请求(按卡片裁剪参数)
    Note over S,M: 轮次中:重试/降级/认证恢复对用户透明
    U->>S: 中途切换模型
    Note over S: 下个轮次重新解析卡片<br/>档位重映射到新模型支持区间
    S->>MM: 解析新模型卡片
    MM-->>S: 新快照
    S->>M: 用新模型继续(历史不变)

几个要点:

  • 切换模型不切换历史。 历史是会话的财产(第 4 章),模型只是处理历史的引擎。换模型后下一轮次用新卡片重新组装请求;推理档位如 3.4.1 所述重映射,其余旋钮同样按新卡片重新门控。
  • 子 agent 用模更严格。 spawn 子 agent 时(第 7 章),harness 会校验子模型确实在目录里、请求的服务等级和推理档位被该模型支持;未知模型上的严格校验会被跳过(兜底卡片已经降级处理)。
  • 目录决定 UI。 前端的模型选择器列表直接来自目录:可见性、排序优先级、是否默认、是否仅 API key 模式可用(ChatGPT 登录和 API key 用户看到的模型集不同)、升级建议与退役倒计时,都是目录数据的投影。模型退役不是“哪天突然不能用”,而是目录里先带上升级建议和退役日期,引导用户迁移。

3.9 小结:面向“不确定远端”的四条设计原则

  1. 能力是数据,不是代码。 模型支持什么、不支持什么,全部沉淀在远程可更新的能力卡片里;每个推理参数发出前都过一道“这个模型支持吗”的门控,不支持就省略或回落,绝不把请求发成错误。未知模型拿到保守兜底档案并降级相关功能。新增模型、新增档位、新增传输格式,大部分时候不需要改 harness 代码。

  2. 状态归属 harness,缓存交给服务。 harness 坚持无状态请求(store=false)、历史只追加、每次请求自足可解释;同时用稳定的缓存键(会话 ID)、WebSocket 增量链和粘性路由配合服务端缓存,并以“非输入字段全等 + 输入严格前缀扩展”的双重校验保证增量优化永远不会在错误的上下文上运行。

  3. 按语义区分故障,连续性是默认值。 瞬时故障(断流、软限流、过载、令牌过期)重试或自愈,重试对轮次状态透明、对用户可见;永久故障(超长、没钱、非法请求、安全拦截)快速认输并给出可操作的信息。重试穷尽还有传输降级兜底。轮次要么向前推进,要么响亮地报告,绝不静默卡死。

  4. 把变化冻结在边界上。 模型档案轮次级冻结、参数步骤级快照、目录热更新只影响下一轮次;同一轮次内“看到的工具、使用的参数、执行的能力”严格一致(呼应第 1 章的步骤快照)。模型会变、目录会变、服务端会改道,但这些变化都被挡在轮次边界之外。

留给读者思考的几个问题

  • harness 一边坚持 store=false 的无状态请求,一边又用缓存键和 previous_response_id 深度依赖服务端状态——“无状态”的边界到底划在哪里?为什么这个边界比“全靠服务端记忆”或“完全不要服务端缓存”都好?(→ 第 4、9 章)
  • 兜底能力卡片选择“什么高级功能都关”,而不是“什么都假设支持”。反过来做会怎样?(提示:一次失败的工具调用和一次缺失的能力,哪个更容易被发现?)
  • 增量请求要求“非输入字段完全一致”——为什么推理档位变了就算上下文前缀没变也必须全量重发?这和第 1 章“插话只在步骤边界注入”是同一种什么约束?
  • 软限流(429 拥塞)在请求级不重试、在流级重试,硬限额(额度耗尽)在哪一级都不重试——如果把硬限额也做成退避重试,用户会看到什么?
  • 服务端把请求改道到另一个模型时,harness 只警告不阻止。什么情况下“警告”就够了,什么情况下必须“拒绝”?这与第 8 章的安全策略边界有什么关系?

下一章我们进入上下文与指令管理:每次请求里那份越来越长的“历史”到底是怎么组装的——消息如何增量累积、超长时如何压缩、环境信息以什么形式注入、系统指令又分哪几层。

分类:Agent Harness标签:#agent #harness #codex

第二章 交互与事件协议:两条方向相反的"消息河流"

第一章我们看到,harness 的运行时是“消息驱动”的:前端与内核之间只流动 Op 和 Event。 但这只是故事的一半。内核的另一侧还面对着一条同样繁忙的消息河流——模型的流式响应。 本章拆开这两条协议:一条面向前端(人或 IDE),一条面向模型(推理服务)。 内核夹在中间,扮演协议网关的角色:把前端的命令翻译成模型请求,把模型的字节流翻译成前端能渲染的事件。


2.1 为什么 harness 需要“两条协议”

普通程序调用一个函数,签名就是它的协议:入参什么类型、返回什么值,编译器帮你保证一致。

Agent harness 没有这种奢侈。它的两侧都是异构、遥远、各自演化的对手:

  • 内侧是模型服务。一个 HTTP(或 WebSocket)端点,请求体里塞上下文和工具清单,回复不是一个 JSON 对象,而是一条可能持续几十秒的事件流——文本一个字一个字地蹦,工具调用的参数逐片段成形,最后才告诉你“我说完了”。中途还可能限流、断连、被服务端安全审查拦下。
  • 外侧是前端程序。可能是终端 TUI、IDE 插件、CLI、app-server 背后的多个网络客户端。它们有的和内核在同一进程里(内存通道),有的隔着进程边界(标准输入输出),有的隔着网络(WebSocket)。它们既要发号施令(“中断!”“批准!”),又要实时渲染(打字机效果、进度条、审批弹窗)。

两侧的协议风格截然不同:

前端协议(Op / Event) 模型协议(Responses 流)
方向 双向:前端发命令,内核发事件 内核发请求,模型推流
形态 离散消息,几十上百种语义类型 一次请求 → 一条有序事件流
时效 命令可能排队、被拒绝、被应答 流只读一次,断了就重来
错误 是一种事件(Error / Warning) 是流上的一个错误项(Err)
取消 显式命令(Interrupt Op) 没有取消消息——直接断开流

理解这张表,就理解了本章的所有设计:协议的形状,是由对话双方的关系决定的。 前端是同事,需要协商、需要确认、需要被征求意见;模型是一个“启动后只负责输出”的推理引擎,关系简单粗暴。

graph LR
    subgraph 外侧["外侧:前端世界"]
        TUI["TUI"]
        IDE["IDE 插件 / 网络客户端"]
        CLI["CLI"]
    end
    subgraph 内核["harness 内核"]
        GW["协议网关:翻译 / 路由 / 持久化"]
    end
    subgraph 内侧["内侧:模型世界"]
        MODEL["Responses API<br/>(SSE / WebSocket)"]
    end

    TUI -->|"Op(命令)"| GW
    IDE -->|"Op(JSON-RPC)"| GW
    CLI -->|"Op"| GW
    GW -->|"Event(事件)"| TUI
    GW -->|"通知 / 反向请求"| IDE
    GW -->|"Event"| CLI
    GW -->|"采样请求(上下文+工具)"| MODEL
    MODEL -->|"流式事件(delta / item / 完成)"| GW

2.2 前端协议:Op 是祈使句,Event 是陈述句

前端与内核之间的全部对话,由两个枚举穷尽:

  • Op(操作):前端 → 内核。祈使句——“做这件事”。
  • Event(事件):内核 → 前端。陈述句——“发生了这件事”。

第一章已经按用途给 Op 分过类(输入类、控制类、审批应答类、维护类)。Event 这边种类更多,值得按“前端拿它干什么”重新归一次类:

事件类别 代表事件 前端拿来做什么
轮次生命周期 轮次开始、轮次完成、轮次中止、关闭完成 驱动状态机(第一章 1.4 的状态图就是从它们推导的)
item 生命周期 item started、item completed(携带结构化 item:user 消息、assistant 消息、reasoning、命令执行、文件改动、压缩记录……) 渲染对话主体;这是权威数据源
流式 delta assistant 文本 delta、reasoning summary delta、raw reasoning delta、plan delta 打字机效果;易失、只用于实时渲染
工具进度 命令开始/输出 delta/结束、终端交互、补丁开始/更新/结束、MCP 调用开始/结束、联网搜索、图片生成 工具专属的进度 UI(终端输出流、diff 预览)
审批与提问请求 命令审批请求、补丁审批请求、权限申请、向用户提问、MCP 输入表单、动态工具调用请求 弹出审批框/提问框;期待一个应答 Op
状态与旁路 token 用量计数、模型改道、流错误(重连中)、警告、错误、安全缓冲、环境连接、MCP 启动进度、压缩完成、回滚完成、hook 开始/完成 状态栏、警告条、toast、成本面板
原始透传 原始模型 item、原始响应完成(含精确 token 用量) 给需要模型协议原始数据的新前端/扩展用
多 agent / 实时语音 子 agent 活动、协作事件族;实时语音会话事件族 子 agent 面板、语音通话 UI

几个设计要点:

1. 事件说“事实”,不说“指令”。 事件描述的是“发生了什么”(一条 assistant 消息完成了、一条命令开始执行了),而不是“你应该把界面刷成什么样”。同一个事件,TUI 可以渲染成气泡,app-server 可以转发成 JSON-RPC 通知,持久化层可以写进日志——消费者各取所需。这是事件能同时服务渲染、状态推导、持久化三个用途的根本原因(第一章结尾留下的思考题,答案就在这里:事件必须是事实的投影,而不是某个 UI 的遥控指令)。

2. 事件携带完整的坐标。 每个事件都带着轮次 ID,多数还带线程 ID 和 item ID。前端是多线程、多轮次、多 item 并发的世界,没有坐标的事件无法被安放。轮次 ID 就是触发该轮次的那条提交的 ID(时间有序的 UUIDv7),所以前端不需要维护自己的“当前轮次”映射——事件自己会声明归属。

3. 入站有界、出站无界。 前端 → 内核的提交通道是有界的(容量 512):命令生产太快时,提交方会被阻塞或收到背压,防止命令无限堆积。内核 → 前端的事件通道是无界的:内核绝不应该因为“前端看得慢”而丢失事件或阻塞工作——事件是真相,真相不能丢。真正的流量控制在更外层解决(app-server 对网络客户端有有界队列和过载错误,见 2.7)。

4. 命令有“信封”。 每条提交不只装一个 Op,还附带:唯一 ID(用于和事件关联)、可选的 W3C 追踪上下文(traceparent,跨越异步边界串联分布式链路,第 12 章可观测性会用到)、以及多 agent 场景下的父轮次/根轮次 ID(子 agent 的消息要能追溯到是谁触发的,第 7 章展开)。

类比:Op/Event 协议像一个餐厅的“点餐-出餐”系统。Op 是客人递进去的小票(点餐、催单、换菜、结账),Event 是后厨向外的广播(3 号桌的菜下锅了、3 号桌的菜上了、3 号桌等的鱼没有了需要换一个——请确认)。小票有编号,广播喊桌号,两相对得上。


2.3 三种对话模式:发后不管、确认即回、问与答

Op/Event 虽然是两个方向的异步消息,但它们组合出了三种不同的对话模式。混淆这三者,是理解协议时最容易犯的错。

模式一:发后不管(fire-and-forget)。 大多数 Op 如此——中断、刷新配置、清理后台终端。前端发出后不期待任何专门的“收到”回复;内核处理后产生的事件本身就是结果(比如中断 Op 最终会引出“轮次中止”事件)。

模式二:确认即回(acknowledge, not result)。 用户输入是最微妙的 Op。前端需要立刻知道“我的消息被接受了吗”,但显然不能等轮次跑完才回复——那可能要几分钟。内核的做法是:输入 Op 携带一个一次性应答通道,内核只回复路由决策——“已开新轮次”“已作为插话注入”或“被拒绝”(忙且不能插话、输入为空等)。这个回复在毫秒级返回;之后轮次的整个生命周期通过常规事件流汇报。

sequenceDiagram
    participant H as 前端
    participant C as 内核
    H->>C: Op:用户输入(带应答通道)
    Note over C: 路由决策:空闲?开新轮次<br/>忙?插话 / 拒绝
    C-->>H: 立即回复:已开新轮次(轮次 ID)
    Note over H: 拿到确认,界面上立刻显示用户消息
    C->>H: Event:轮次开始
    C->>H: Event:文本 delta……
    C->>H: Event:轮次完成

这个切分非常关键:“接受请求”和“完成请求”是两个独立的信号。 前端 UI 可以在收到确认的瞬间就把用户消息画上屏(乐观渲染),而不必等待任何模型输出。

模式三:问与答(request-response,方向反转)。 第一章 1.3 已经见过:内核需要授权或提问时,发出一个审批/提问请求事件,然后在轮次内部挂起一个一次性等待通道;前端的应答以专门的应答 Op 回来(批准命令、批准补丁、回答提问、提交权限授予、填写 MCP 表单……),循环取出后唤醒挂起的工具调用。

sequenceDiagram
    participant H as 前端
    participant C as 内核(提交循环)
    participant T as 轮次任务
    T->>H: Event:审批请求(命令详情、请求 ID)
    Note over T: 挂起等待……
    Note over H: 用户在弹窗里点"批准"
    H->>C: Op:审批应答(请求 ID + 决定)
    C->>T: 通过等待通道唤醒
    Note over T: 命令执行,轮次继续

第三种模式的深意在于:“内核向前端发请求”是协议的一等公民,不是特例。 审批、向用户提问、权限申请、MCP 表单、动态工具(把工具调用委托给前端执行)——人机协同、前端能力扩展,全部复用这同一个“反转的请求-应答”。第 8 章安全策略和第 11 章可扩展性都会回到它。

此外还有一条状态广播通道独立于事件流:代理状态(空闲/运行中/已中断……)用一个 watch 通道发布,新订阅者立刻拿到当前值,之后收到变化推送。它表达的是“此刻的状态”,而事件流表达的是“发生过的事”——状态是事件流的投影,但为了方便随时查询,单独暴露了一份最新值。


2.4 模型协议:一次请求,一条事件流

现在把椅子转向内侧。模型说的是 Responses API 的语言。

2.4.1 请求:把整个“世界”打包

一次采样请求的本质,是把 harness 此刻掌握的全部相关信息打成一个请求体:

  • 输入历史:一串有序的 item——用户消息、assistant 消息、reasoning 记录、工具调用及其结果、各种带标记的环境片段(第 4 章的主题);
  • 指令:系统级基础指令(工具怎么用、行为准则);
  • 工具清单:这一步对模型可见的工具及其 JSON Schema(第 6 章的主题);
  • 推理控制参数:reasoning effort(推理强度)、是否要 reasoning summary、并行工具调用开关等(第 3 章展开);
  • 以及模型名、缓存键、结构化输出 schema 等。

请求发出后,回复不是一个对象,而是一条服务器推送事件流(SSE)——或者在支持时走一条复用的 WebSocket 连接。内核像读日志一样逐行读取事件,直到流的终点。

2.4.2 流的结构:item 为骨,delta 为肉

模型流的事件可以按“骨架”和“血肉”分开理解。

骨架是 item 的生命周期。一次响应产出若干个输出 item(item,即流中的一个完整输出单元:一段 assistant 消息、一段 reasoning、一个工具调用……),每个 item 经历“添加 → 完成”:

  • response.created:响应开始;
  • response.output_item.added:一个新 item 出现(此时知道它的类型和 ID,但内容还空着);
  • response.output_item.done:这个 item 完整了(全文本、完整的工具调用名和参数);
  • response.completed:整个响应结束,附带 token 用量统计和一个关键标志位 end_turn

血肉是 item 内部的 delta(delta,即流式增量片段),在 added 和 done 之间持续到达:

  • assistant 文本 delta(output_text.delta):一个个字片段,打字机效果的来源;
  • reasoning summary delta(reasoning_summary_text.delta):模型愿意展示的那部分思考摘要,还分“段落”(part),每个段落一个标题块;
  • raw reasoning delta(reasoning_text.delta):加密的原始思维链(chain-of-thought,CoT)。模型厂商不希望思维链明文离开服务端,但为了多轮一致性,允许它以加密形式随上下文回传——内核只负责搬运,永远看不到明文。
sequenceDiagram
    participant C as 内核
    participant M as 模型服务
    C->>M: 采样请求(历史 + 工具 + 指令)
    M-->>C: created(响应开始)
    M-->>C: output_item.added(reasoning item)
    M-->>C: reasoning summary delta ×N
    M-->>C: output_item.done(reasoning item 完成)
    M-->>C: output_item.added(assistant 消息 item)
    M-->>C: 文本 delta ×N
    M-->>C: output_item.done(assistant 消息完成)
    M-->>C: output_item.added(工具调用 item)
    M-->>C: 工具参数 delta(仅自由格式工具)
    M-->>C: output_item.done(工具调用成形)
    Note over C: 工具立刻开始执行(不必等流结束)
    M-->>C: completed(token 用量 + end_turn 标志)
    Note over C: 回灌工具结果,决定是否再次采样

注意 completed 里的 end_turn 标志:它表示模型是否明确认为“我这轮说完了”。如果模型在回复里调了工具,或者标志位显式为 false,内核就知道还要再发起一次采样(工具结果回灌后继续);否则轮次收尾。轮次是否结束,最终由这个标志和工具调用情况共同决定——呼应第一章 agentic 主循环的契约:“要么工具调用,要么最终回复”。

2.4.3 那些被故意忽略的事件

模型流里其实还有不少事件类型,内核显式地丢弃了它们(只记日志,不处理):

  • response.in_progress:created 之后的冗余状态;
  • response.output_text.donecontent_part.added/done:delta 流完时的“收尾”事件,内容和 done item 重复;
  • response.function_call_arguments.delta:普通工具调用的参数 JSON delta。

最后一个尤其值得一问:工具参数也是一个字一个字生成的,为什么不像文本一样做流式展示?

因为半截 JSON 对人没有意义,还可能误导。普通工具(执行命令、读文件)的参数是结构化数据,在闭合之前它不是合法 JSON,渲染出来只是一串括号和引号——用户既看不懂,也无法据此预览任何东西。内核选择等工具调用整体成形(output_item.done)后,再靠工具自己执行期间发出的进度事件(命令输出流、补丁预览)来展示。

那为什么补丁工具(apply_patch)例外?因为它是自由格式工具(freeform tool):参数不是 JSON,而是一整段人类可读的补丁文本。补丁是逐行生成的,每一行都有意义——内核能边接收边解析,把“将要改动哪些文件、每个文件改成什么样”实时预览出来(模型流里对应 custom_tool_call_input.delta 事件,内核翻译成补丁更新事件,还做了 500ms 节流避免刷爆界面)。

这是一个普适的协议设计教训:不是所有“能流式”的数据都“值得流式”。delta 通道只承载对消费者有独立意义的片段。

2.4.4 错误:流上的“异常项”,而不是事件

模型协议里一个反直觉的设计:错误不是事件,而是流上的一个 Err

流的类型是“事件的序列”,但每个元素是 Result<事件, 错误>。流可能以这些方式异常终止:

  • 网络断连、超时、流在 completed 之前意外关闭;
  • response.failed:服务端明确报告失败,携带错误码;
  • response.incomplete:响应不完整(比如被安全策略截断),附带原因。

错误还分致命可重试两类,语义完全不同:

错误 性质 内核的反应
上下文超长(context window exceeded) 致命 不重试,交给压缩逻辑处理(第 4 章)
配额/用量耗尽 致命 不重试,向用户报告额度问题
安全策略拦截(cyber policy 等) 致命 不重试,转错误事件
请求非法(invalid request) 致命 不重试,这是 bug 或不支持的用法
限流(rate limit)、服务过载、网络抖动、流中断 可重试 指数退避后重发请求

为什么错误用 Result 而不是一个“错误事件”?因为模型流只有一个消费者(内核),错误是控制流而不是数据。Rust 的 Result 天然表达“流在这里断了”,消费者无法忽略它(不处理错误就拿不到后面的事件——后面本来也没有了)。对比前端协议:事件有很多消费者(UI、状态机、持久化、app-server 的多个客户端),错误必须作为一条数据广播出去,让每个消费者各自决定怎么呈现。

同一个概念,在一对一的流上是异常,在一对多的总线上是消息。

2.4.5 重试:对轮次透明,对用户可见

可重试错误触发重试循环:传输层在连接阶段就有重试(默认 4 次,指数退避加随机抖动,避免重试风暴);流已经开始后断掉,还有流级重试(默认 5 次)。WebSocket 传输重试耗尽时,会自动回退到 HTTP SSE 再来一轮。

重试期间轮次不结束、状态不变。前端会收到咨询性的“流错误”事件(文案是“重连中…… 2/5”),UI 可以显示一个低调的提示;重试成功,事件流继续,像什么都没发生过。

这里有一个和第 2.2 节呼应的重要取舍:delta 通道不保证可靠,权威通道保证可靠。 重试时请求是用持久化的历史重新组装的——已完成的 item(工具调用、工具结果)在成形的那一刻就已经落历史、落持久化,所以重试不丢工作;但已经吐给前端的 delta 不会重放。如果前端在断连期间漏了几个字片段,它不会被补发——前端以 item completed 事件里的完整内容为准。delta 只是实时渲染的加速带,item 才是事实本身。这就是为什么 2.2 节把事件分成“流式 delta(易失)“和”item 生命周期(权威)“两类:这个分类不是随意的,它对应着失败恢复时的两种待遇。

2.4.6 取消:没有取消消息,断开就是取消

模型协议里找不到“取消”这个动作。中断发生时(第 1.5 节的协作式取消),内核的做法是:取消令牌触发 → 读取流的 future 被放弃 → 流对象被 drop → 底层连接关闭。服务端发现连接断了,自然停止生成。

这是分布式系统的典型哲学:取消是连接层面的事实,不需要应用层消息。 设计一条“请取消”的消息反而引入新问题(消息丢了怎么办?服务端忙着生成没看到怎么办?)。TCP 连接的关闭本身就是最可靠的取消信号。

对比前端侧:中断是一个显式 Op。为什么这边需要消息?因为内核不能被“一枪毙掉”——它要做协作式收尾(给任务优雅期、追加中断标记、保留后台进程、发出中止事件)。对模型,内核只需要它“闭嘴”;对前端,内核需要“体面地停下并交代清楚”。 关系再次决定了协议的形状。

2.4.7 两种传输:SSE 与 WebSocket

模型流有两种传输方式,共享同一套事件格式:

  • HTTP SSE:一次 POST 请求,响应是事件流。简单、通用、兼容所有 provider;每次采样都是独立请求。
  • WebSocket:与服务端建立一条长连接,多次采样复用它,请求可以是增量的(incremental,只发新增的历史,配合服务端的 previous_response_id),还支持预热(连接上先发起一个不生成的请求,让服务端把上下文缓存好)。连接有 60 分钟上限,到期按错误码重连;出问题可整体回退到 SSE。

WebSocket 模式下还有一个细节:轮次开始时服务端会通过响应头发下一个粘性路由令牌(turn-state token),同一轮次内的后续请求(重试、续接)都带上它,保证同一轮对话被路由到服务端同一后端实例——那里缓存着这轮的上下文。令牌严禁跨轮次复用:新轮次换新令牌,旧令牌随轮次结束而失效。这是第 4 章“上下文缓存”主题在协议层的伏笔:harness 一边在本地增量累积历史,一边配合服务端的缓存亲和性,两边共同压低重复计算。


2.5 翻译层:内核如何把模型流“说成人话”

两条协议并不直接对话,中间隔着内核的翻译层。每收到一个模型流事件,翻译层决定:发什么前端事件、记什么历史、是否触发动作。

flowchart TD
    SE["模型流事件"] --> T{"事件类型"}
    T -->|"item added(消息/reasoning)"| A["发:item started(权威渲染起点)"]
    T -->|"文本 delta"| B["发:assistant 文本 delta<br/>(剥离引用标记、计划块等噪声)"]
    T -->|"reasoning summary delta"| C["发:reasoning delta / 段落分隔"]
    T -->|"raw reasoning delta"| D["发:raw reasoning delta"]
    T -->|"item done(消息/reasoning)"| E["记入历史 + 发:item completed"]
    T -->|"item done(工具调用)"| F["立即记入历史<br/>spawn 工具并行执行<br/>工具自己发进度事件"]
    T -->|"补丁参数 delta"| G["边收边解析 → 发:补丁更新预览"]
    T -->|"completed"| H["发:原始响应完成 + token 用量<br/>后补发:用量计数事件"]
    T -->|"服务端模型与请求不符"| I["发:模型改道警告(每轮一次)"]
    T -->|"安全缓冲 / 限流 / 验证建议"| J["发:对应旁路通知"]
    T -->|"错误 Err"| K["可重试?发:重连中 → 退避重试<br/>致命?发:错误事件,轮次失败"]

翻译层有几个值得驻足的设计:

1. 工具调用在“成形”的那一刻就启动,不等流结束。 工具 item done 时,内核立刻把它记入历史(即使轮次之后被取消,历史也保持完整)并 spawn 执行——工具执行与模型继续输出后续内容在时间上重叠。流结束后,内核再严格按模型发出调用的顺序回收工具结果(第一章 1.4 的“并行执行、按序回灌”)。协议层面这意味着:工具的生命周期事件(命令开始、输出 delta、结束)是穿插在模型文本流事件里的,前端必须接受这种交错,并靠事件上的 item ID/轮次 ID 把它们归位。

2. 翻译会“清洁”模型输出。 模型的文本 delta 里夹杂着一些给机器看的标记:引用来源标记、计划模式下的计划块等。翻译层在流式阶段就把它们解析剥离——计划文本走专门的计划事件,引用标记附加到最终消息的元数据里,前端看到的 assistant 文本是干净的。模型协议是给模型生态用的,前端事件是给人用的,两者之间的清洁工作由翻译层承担。

3. 旁路信息各有去处。 响应头和流里还夹带各种元信息:实际服务的模型名(安全路由可能把高风险请求改道到别的模型,内核发“模型改道”事件,每轮至多一次)、安全缓冲状态(服务端安全审查期间输出被缓冲,UI 显示“审查中”,必要时换更快的模型重试)、限流配额快照(攒到响应结束后随用量事件一起发,避免刷屏)、账号验证建议等。它们不进模型历史,只作为旁路通知存在。

4. 每个完成的 item 都“双发”。 一个 item 完成时,前端同时收到两类东西:一个结构化 item 事件(翻译层整理过的、语义化的 item——用户消息、assistant 消息、命令执行、文件改动……),以及一个原始 item 事件(未经加工的模型协议原生对象)。前者给 UI 渲染用,后者给需要原始数据的消费者用。下一节解释这个“双发”的由来。


2.6 三代事件并存:协议如何演进

一个活的协议必须能演化。Codex 的事件体系里实际上沉淀着三代事件,理解它们的共存关系,才能理解为什么协议长成今天这样:

第一代:整消息事件。 最早的事件是“整条消息”粒度的:一条 assistant 消息事件(携带完整文本)、一条 reasoning 事件、命令开始/结束。简单,但无法做流式 UI——消息不到齐就什么都显示不了。

第二代:delta + item 生命周期。 为了打字机体验,引入了 delta 事件(文本 delta、reasoning delta);随后又引入了更规整的 item started/completed 模型:轮次由若干 item 组成,每个 item 有明确的开始和完成,delta 只是 item 内部的流动。item 是权威的、完整的、可持久化的;delta 是易失的渲染加速带(2.4.5 的重试语义就建立在这个区分上)。

第三代:原始透传事件。 app-server 这类新前端希望自己掌握解释权——它们要的是模型协议里的原始 item,而不是内核整理过的语义视图。于是每个写入历史的 item 都原样透传一份(原始 item 事件),响应结束时还有原始完成事件(携带服务端报告的精确 token 用量,不估算、不累计)。

三代不是替换关系,而是叠加关系:内核在发送第二代规范事件时,会自动派生出第一代事件(老客户端不改代码也能继续工作);同时旁路透出第三代原始事件(新客户端各取所需)。事件只增不废,旧事件从“核心契约”降级为“兼容投影”。

graph TD
    subgraph 内核翻译层
        CANON["规范事件(第二代)<br/>item started/completed + delta"]
    end
    CANON -->|"自动派生"| LEGACY["第一代事件<br/>整消息 / 命令开始结束"]
    CANON --> RAW["第三代事件<br/>原始 item 透传"]
    HIST["历史记录(写入时)"] --> RAW
    LEGACY --> OLD["旧版前端:TUI 旧版 / 旧 rollout"]
    CANON --> NEW["新前端:结构化 UI"]
    RAW --> EXT["app-server v2 / 扩展 / 成本核算"]

这个策略的代价是内核里多了一层“事件扇出”逻辑(每条规范事件都过一遍派生函数),收益是协议升级不造成生态断裂:持久化的历史事件流(第 10 章会讲,rollout 是唯一真相源)里可能躺着十年间各版本的事件,反序列化时新代码必须全部读得懂——#[serde(default)]、字段别名、旧格式兼容转换在协议类型里随处可见。事件一旦发出就是永恒的,因为它会被持久化、被回放、被跨版本读取。 这是事件协议和普通函数 API 最大的不同:函数签名改了,编译期就能发现所有调用方;事件格式改了,磁盘上三年前的日志不会自己更新。


2.7 前端形态:同一套语义,三种传输

Op/Event 是语义协议,不绑定传输方式。同一件事(“用户输入”→“轮次开始”→ delta → “轮次完成”)在三种前端形态下字节形态完全不同,但消息模型完全一致:

形态一:进程内直连(TUI、CLI)。 前端和内核在同一进程里,消息走内存通道,没有 JSON 序列化。有趣的是,TUI 现在并不直接对接内核的 Op/Event,而是接入一个进程内 app-server:TUI 发出的请求、收到的通知,与网络客户端跑的是同一套 JSON-RPC 语义契约(强类型请求、通知信封、反向请求),只是底层传输从 socket 换成了内存通道、握手自动完成。这样 TUI 和 IDE 插件共享同一套协议行为,差异在开发期就被消弭,而不是靠“两个客户端各自理解一遍内核”来维持一致。app-server 再往下,才翻译成内核的 Op/Event。

形态二:标准输入输出(本地 IDE 插件)。 前端 spawn 一个 app-server 子进程,通过 stdio 交换换行分隔的 JSON(一行一条消息)。这是最通用的本地形态:任何语言、任何进程都能参与,不占端口,天然随父进程生死。

形态三:网络服务(WebSocket / Unix socket)。 app-server 监听端口或本地 socket,支持多个客户端同时连接。多连接带来了单进程形态没有的新问题,协议为此增加了几样东西:

  • 握手:连接建立后先交换 initialize / initialized(客户端报上身份和能力,服务端回报环境信息),握手前拒绝一切请求;
  • 能力协商与实验门控:客户端声明是否接受实验性 API;实验方法/字段对未声明的客户端隐藏或报错,schema 分稳定版/实验版两套导出;
  • 订阅模型:客户端通过“启动/恢复/分叉线程”自动订阅该线程的事件流,也可显式退订;通知按连接扇出,客户端还能按方法名精确屏蔽自己不关心的通知;线程在最后一个订阅者离开后保留一段时间才卸载;
  • 反向请求:服务端也能向客户端发请求(审批、提问、表单、动态工具),客户端必须应答——2.3 节的“问与答”模式在网络上变成正式的双向 RPC;轮次结束时未决的反向请求会被明确中止,不会永远挂着;
  • 背压与过载:入站、处理、出站之间都是有界队列;服务器忙不过来时新请求收到明确的“过载,请重试”错误(而不是静默排队或丢消息);但反向请求绝不允许静默丢弃——审批请求丢了会导致轮次永久挂起,所以宁可失败返回也不吞掉。
sequenceDiagram
    participant C1 as 客户端 A(IDE)
    participant C2 as 客户端 B(面板)
    participant S as app-server
    participant K as 内核
    C1->>S: initialize(身份 + 能力)
    S-->>C1: 环境信息
    C1->>S: initialized(通知)
    C1->>S: thread/start → turn/start(用户输入)
    S->>K: Op:用户输入
    K-->>S: 确认:已开轮次
    S-->>C1: turn/started
    C2->>S: initialize + thread 订阅
    K->>S: Event:文本 delta
    S-->>C1: 通知:item/agentMessage/delta
    S-->>C2: 通知:item/agentMessage/delta
    K->>S: Event:审批请求
    S-->>C1: 反向请求:requestApproval
    Note over C2: 审批只定向发给相关连接
    C1->>S: 应答:批准
    S->>K: Op:审批应答

方法命名也反映了“资源/动作”的 REST 式风格:thread/startturn/steerturn/interruptconfig/readfs/readFile…… 字段统一 camelCase,时间戳用 Unix 毫秒。内核的 Op/Event 到这些 JSON-RPC 方法之间有一层薄薄的投影:多数事件是一对一翻译(轮次开始 → turn/started,文本 delta → item/agentMessage/delta),少数需要聚合状态的(如轮次完成时汇总最终状态和用量)在 app-server 层完成。


2.8 小结:协议设计的六条原则

  1. 协议形状由对话关系决定。 前端是需要协商的同事:命令要确认、提问要回答、取消要体面收场,所以前端协议是丰富的双向消息总线。模型是“启动即输出”的引擎:流只读一次、错误即中断、取消即断连,所以模型协议极简。内核是两者之间的网关。

  2. 事件陈述事实,不发布指令。 同一条事件流同时驱动 UI 渲染、状态推导、持久化回放,因此事件必须是“发生了什么”的事实投影,而不是给某个 UI 的遥控信号。事件自带线程/轮次/item 坐标,消费者自行归位。

  3. 权威与易失分层。 item(完整、成形、落历史)是权威的,可重放、可重建;delta(片段、流式)是易失的渲染加速带,失败重试时不补发。任何消费者都必须能仅凭 item 事件重建完整 UI——这个约束让断连重试、迟到订阅、历史回放都变得简单。

  4. 错误按“关系”选择表达方式。 一对一的流上,错误是 Result(无法忽视的控制流);一对多的总线上,错误是事件(广播给所有消费者的数据)。致命错误与可重试错误严格区分,重试对轮次状态透明、对用户可见(“重连中”),且不丢已完成的工作。

  5. 反转的请求-应答是一等公民。 内核向前端发请求(审批、提问、表单、委托执行)与前端向内核发命令共用同一套消息机制。人机协同和能力扩展因此不是补丁,而是协议的原生形态。网络传输下它还配套了“必须应答、不可丢弃、轮次结束即中止”的严格语义。

  6. 协议只增不废,事件即永恒。 三代事件(整消息、item+delta、原始透传)叠加共存,新事件自动派生旧事件;事件会被持久化并跨版本回放,所以兼容性是协议类型的硬约束——这也是第 10 章“事件溯源持久化”的前提。

留给读者思考的几个问题

  • 模型流里“错误是 Err 不是事件”,前端协议里“错误是事件”——如果让模型流也把错误做成事件,会多出哪些失败模式?(提示:流只有一个消费者这件事意味着什么?)
  • delta 不保证可靠、item 保证可靠——如果一个前端只订阅 delta 而忽略 item completed 事件,在重试/重连场景下会看到什么?这对 UI 代码的状态管理提出了什么要求?
  • 审批请求“绝不允许静默丢弃”,而普通通知在服务器过载时可以被丢弃或拒绝——为什么协议对这两类流量给出相反的可靠性承诺?(→ 第 8、9 章)
  • TUI 为什么要“自降身段”走进程内 app-server,而不是直接调内核?多一层协议转换换来的是什么?(→ 第 11 章)
  • 事件格式要兼容十年前的持久化日志,而工具清单、模型能力每个月都在变——harness 如何在“协议冻结”和“能力快速演化”之间腾挪?(→ 第 6 章工具暴露面、第 11 章扩展机制)

下一章我们进入模型接入与推理控制:采样请求里那些参数(推理强度、摘要、工具选择、缓存键、结构化输出)如何影响模型行为,harness 又如何在模型故障、限流、改道时维持轮次的连续性。

想自己写前端接入? 附录 A《前端对接协议参考》给出了 app-server JSON-RPC 的完整消息目录、字段约定、审批反向请求时序和最小客户端骨架,可直接作为对接手册使用。

分类:Agent Harness标签:#agent #harness #codex

第一章 运行时模型:Agent 是如何被"驱动"起来的

本系列以 OpenAI Codex(Rust 实现)为样本,拆解一个生产级 agent harness(脚手架/运行时框架)的设计。 所谓 harness,指模型之外的一切驱动逻辑:会话管理、轮次调度、工具执行、审批沙箱、上下文维护、持久化恢复…… 模型本身只是一个“给它输入、它吐输出”的推理引擎;是 harness 让它变成一个能连续做事、能被打断、能与人协作的 agent。


1.1 为什么 agent 需要一个“运行时”

传统程序的控制流是程序员写死的:调用 A,拿到结果,再调用 B,分支条件都在代码里。

Agent 程序的控制流则是倒置的:每一步做什么,由模型根据当前上下文临时决定。模型可能说“我要调一个工具”,也可能说“我做完了,这是答案”,还可能说“我需要问用户一个问题”。没有人能预先知道下一行“代码”是什么。

这就要求模型外面包一层运行时,来回答一系列问题:

  • 一次对话从哪里开始、什么时候算结束?
  • 模型正在跑的时候,用户突然插话怎么办?
  • 模型跑飞了,怎么停下来?停下来之后还能接着跑吗?
  • 进程崩溃重启了,之前的对话还能续上吗?
  • 一个 agent 忙不过来,想再派几个“分身”并行干活,怎么管?

这些问题的答案,构成了 harness 的运行时模型:它定义了“有哪些层级的运行实体、它们各自的生命周期是什么、彼此之间如何传递控制权”。

运行时模型是整个 harness 的骨架。后面章节讲的事件协议、工具系统、安全策略、上下文管理、多 agent 编排,都是挂在这副骨架上的器官。


1.2 五个层级:从进程到一次模型调用

Codex 的运行时可以分为五个层级,从大到小依次嵌套:

graph TD
    TM["线程管理器 ThreadManager<br/>(进程级,唯一)"]
    TM --> T1["线程 Thread / 会话 Session<br/>一段持续的对话,拥有独立历史"]
    TM --> T2["线程 Thread(子 agent)"]
    T1 --> TU["轮次 Turn<br/>用户一次输入 → agent 跑到空闲"]
    TU --> TK["任务 Task<br/>轮次的执行载体:常规 / 审查 / 压缩"]
    TK --> S1["步骤 Step<br/>一次模型采样请求"]
    TK --> S2["步骤 Step"]
    TK --> S3["步骤 Step"]
    S1 -.->|"工具调用并行执行"| TOOL["工具调用 ×N"]
层级 是什么 生命周期 关键不变量
线程管理器 进程级的“大管家”,持有所有线程,共享认证、模型目录、MCP 管理等重资源 与进程同寿 一个进程一个
线程(Thread/Session) 一段持续的对话,拥有独立的消息历史和后台事件循环 从创建/恢复到关闭 同一时刻至多一个活跃轮次
轮次(Turn) 用户一次输入所触发的完整工作单元:从提交输入到 agent 给出最终回复或被中止 秒级到分钟级 有唯一 ID,状态可观测
任务(Task) 轮次的执行载体。常规对话、代码审查、上下文压缩是三种不同任务 与轮次大致同寿 可被取消令牌中止
步骤(Step) 轮次内部的一次模型采样请求:发上下文 → 收流式回复 → 执行工具 → 回灌结果 一次 HTTP 请求的时长 每次步骤冻结一份配置快照

几个值得注意的设计细节:

1. “线程”和“会话”基本是同义词。 对外叫 thread(线程),对内的运行时结构叫 session(会话)。前端程序(host,指接入并承载内核的外部程序,如 TUI、IDE 插件、CLI、app-server 客户端;下文简称“前端”)拿到的只是一个薄句柄(双向消息管道),真正的状态全部藏在内核里。

2. ID 体系暗示了层级关系。 三类 ID 都是 UUID,但生成方式和作用域不同:

  • Session ID:一棵 agent 树共享一个。根线程的 session ID 直接取自它自己的线程 ID,派生出的子 agent 全部继承这同一个 ID;进程重启后恢复线程时,session ID 也从持久化记录中原样还原。
  • Thread ID:每个线程一个(时间有序的 UUIDv7),父子派生关系持久化下来构成一棵树。
  • Turn ID:用户触发的轮次使用时间有序的 UUIDv7(它就是该次输入的提交 ID,编码了创建时间),前端直接把它作为公开的轮次标识——每个事件都携带它,用来把事件流关联回具体轮次。有两个例外:子 agent 来信自动唤醒的合成轮次使用随机 UUIDv4;恢复(recover)轮次不生成新 ID,而是沿用被中断轮次的原 ID,对外表现为“同一个轮次暂停后继续”。

3. Task 层的存在是为了“轮次也有不同种类”。 用户聊天触发的是常规任务;请求代码审查会跑一个审查任务;上下文太长时压缩历史是压缩任务。它们共用同一套生命周期(创建、运行、中止、收尾),但内部逻辑不同。把“轮次的内容”和“轮次的生命周期”分开,运行时就能用统一的方式调度它们。

类比:线程管理器像餐厅经理,负责开台和共享资源;一个线程像一桌客人的整个用餐过程;一个轮次像客人点一次单到这道菜上完;一个步骤像后厨向客人汇报一次“菜做到哪了”并根据反馈决定下一步;而任务类型区分了“正餐”“甜点”“清台”这几种不同的服务流程。


1.3 消息驱动:一切皆消息,单循环串行

前端(TUI、IDE 插件、CLI、app-server 客户端等)和 harness 内核之间,只有两种消息

  • Op(操作):前端 → 内核。“用户说了句话”“用户点了中断”“用户批准了这条命令”……
  • Event(事件):内核 → 前端。“轮次开始了”“模型说了这段话”“工具在跑命令”“轮次结束了”……

每个线程内部跑着一个后台任务——提交循环(submission loop):它从一个有界队列里逐条取出 Op,串行处理,处理过程中不断把 Event 推回给前端。

sequenceDiagram
    participant Host as 前端(TUI / IDE / CLI)
    participant Queue as 提交队列
    participant SubmitLoop as 提交循环(单任务,串行)
    participant Turn as 轮次任务(可并发 spawn)
    participant Model as 模型

    Host->>Queue: Op:用户输入
    SubmitLoop->>Queue: 取出 Op
    SubmitLoop->>Turn: spawn 轮次任务
    Turn->>Model: 采样请求(流式)
    Model-->>Host: Event:文本 delta / reasoning 流
    Host->>Queue: Op:用户插话(steer)
    Note over SubmitLoop: 轮次在跑,Op 继续排队<br/>循环不被阻塞
    Model-->>Turn: 回复:工具调用
    Turn->>Turn: 并行执行工具
    Turn->>Model: 回灌结果,再次采样
    Model-->>Host: Event:最终回复
    Turn-->>SubmitLoop: 轮次结束
    SubmitLoop->>Queue: 处理排队的插话 Op

这个设计的核心取舍是:Op 的处理严格串行,所有并发都在轮次内部显式开启。

为什么不搞多线程并发处理 Op?因为一个会话的状态(历史消息、当前轮次、待审批请求……)是高度共享的。串行循环让这些状态的读写天然无锁、无竞争——不存在“两个 Op 同时修改历史”这种问题。需要并发的重活(模型流式请求、工具执行、压缩)由循环内部再 spawn 出去,循环本身继续保持轻量和响应性:哪怕轮次在跑,中断、审批应答这类控制类 Op 依然能立刻被取到并处理。

Op 大体分四类:

类别 代表操作
输入类 用户输入(内部再决定开新轮次还是插话)、子 agent 来信、恢复轮次
控制类 中断、关闭会话、清理后台终端、刷新配置
审批应答类 批准命令、批准补丁、回答提问、权限授予响应
维护类 手动压缩、回滚轮次、代码审查

其中最精妙的一点是:内核“向用户提问”也是消息

当工具执行需要授权(比如要跑一条危险命令),内核并不拥有任何“对话框”——它只是发出一个审批请求事件,然后在轮次内部挂起一个一次性的等待通道(oneshot),任务就此停住。用户在界面上点“批准”,这个回答作为一个审批应答 Op 进入队列,循环取出后通过那个等待通道唤醒挂起的工具调用,任务继续。

sequenceDiagram
    participant Turn as 轮次任务
    participant SubmitLoop as 提交循环
    participant Host as 前端

    Turn->>Host: Event:请求审批(命令详情)
    Note over Turn: 挂起,等待应答……
    Host->>SubmitLoop: Op:审批应答(批准)
    SubmitLoop->>Turn: 通过等待通道唤醒
    Note over Turn: 命令执行,轮次继续

这意味着人机协同(human in the loop)不是一套特殊机制,而是消息协议的自然延伸。工具问用户、子 agent 给父 agent 发消息、外部插件应答,走的都是同一个“发事件 → 挂起 → 收 Op → 唤醒”模式。第 2 章讲事件协议、第 8 章讲安全审批、第 9 章讲 Human in the loop 时还会回到这个设计。


1.4 轮次的生命周期

1.4.1 状态机

每个线程对外暴露一个代理状态(AgentStatus),它是一个简单的有限状态机:

stateDiagram-v2
    [*] --> PendingInit: 线程创建
    PendingInit --> Running: 轮次开始
    Running --> Completed: 模型给出最终回复
    Running --> Interrupted: 用户中断 / 预算耗尽
    Running --> Errored: 不可恢复错误
    Interrupted --> Running: 恢复(recover)或新输入
    Completed --> Running: 下一次用户输入
    Errored --> Running: 下一次用户输入
    PendingInit --> Shutdown: 关闭会话
    Running --> Shutdown: 关闭会话(先中止活跃轮次)
    Interrupted --> Shutdown: 关闭会话
    Completed --> Shutdown: 关闭会话
    Errored --> Shutdown: 关闭会话
    Shutdown --> [*]

注意 Interrupted(已中断)不是终态——它表达“轮次停了,但线程还活着,可以继续”。这是“打断后还能恢复”的关键。Completed(已完成)、Errored(出错)、Shutdown(已关闭)才是终态。

关闭会话可以发生在任何存活状态——无论线程是刚创建还没跑过轮次(PendingInit)、轮次正在运行(Running)、还是空闲在已完成/已出错/已中断状态,前端都可以发起关闭。关闭操作在提交循环里是无条件处理的:若有活跃轮次先中止它,然后回收全部资源(中止任务、关闭 MCP 连接、终止后台进程、flush 持久化),最后发出“关闭完成”事件。典型触发场景有三类:用户退出 TUI/CLI;app-server 在线程被删除或最后一个订阅者断开且线程空闲时自动卸载;子 agent/审查线程完成收尾。除了显式关闭,提交循环还有一条隐式终止路径:当所有提交端都被丢弃时,循环取不到消息会自行退出并执行同样的资源回收——这相当于“拔掉电话线”,来不及也不需要等待确认,正常退出走显式路径,异常拆解走隐式路径。

状态不是内核自己拍脑袋改的,而是从输出事件中推导出来的:看到“轮次开始”事件就是 Running,看到“轮次完成”就是 Completed,看到“轮次中止”且原因是中断/预算就是 Interrupted,看到“关闭完成”就是 Shutdown。事件是唯一事实源,状态只是事件流上的一个投影(这也为第 10 章的事件溯源持久化埋下伏笔)。

1.4.2 输入路由:开新轮次、插话,还是拒绝

用户输入到达时,内核要做一个路由决策,这是全系统唯一做这个决策的地方:

  • 开新轮次(start):线程空闲,输入开启一个新 turn;
  • 插话(steer):有正在跑的常规轮次,输入注入进去(详见 1.6);
  • 拒绝(not submitted):忙且不能插话、输入为空、或指定的轮次已经结束——输入被原样拒绝,不记录、不入队、不改变任何状态

前端可以用三种模式表达自己的意图:

  • StartIfIdle:只在空闲时开新轮次,忙就拒绝(比如自动触发的场景,不该打断用户正在进行的工作);
  • StartOrSteer:空闲就开,忙就插话(用户手动发消息的默认行为);
  • Steer(指定轮次 ID):只给指定的那个轮次插话;如果它已经结束了就拒绝(防止把消息发给错误的轮次)。

一个重要的工程细节:输入可以携带设置变更(比如切换模型、调整审批策略)。内核会先“预览校验”这些设置,输入被接受后才真正应用。这样被拒绝的输入不会在系统里留下半应用的配置——“要么全发生,要么不发生”。

1.4.3 轮次内部:agentic 主循环

轮次一旦启动,就进入 agentic 循环(也叫 ReAct 式循环)。它和模型之间的契约非常简单:

每次采样,模型要么返回工具调用,要么返回最终回复。 是工具调用,就执行、把结果喂回去、再采样一次;是最终回复,轮次结束。

flowchart TD
    A([轮次开始]) --> B[轮次前检查:必要时先压缩上下文]
    B --> C[捕获步骤快照 StepContext<br/>记录环境变化]
    C --> D[运行会话开始 / 用户输入 hooks]
    D --> E{"还有事要做?"}
    E -->|是| F[排空待处理输入<br/>插话消息 / 子 agent 来信]
    F --> G[组装本次采样的上下文<br/>历史 + 环境片段 + 工具清单]
    G --> H[向模型发采样请求<br/>流式接收回复]
    H --> I{"模型回复内容"}
    I -->|工具调用| J[工具并行执行<br/>结果按调用顺序回灌历史]
    I -->|最终回复| K[运行停止 hooks]
    J --> L{token 预算检查}
    L -->|超限| M[自动压缩 / 开新上下文窗口]
    M --> E
    L -->|未超限| E
    K -->|hook 要求继续| F
    K -->|允许停止| N([轮次完成])

这个循环里有几个值得展开的点:

两级快照。 轮次级有一份 TurnContext(整个轮次不变的东西:工作目录、模型、策略……);每个步骤开始时再捕获一份 StepContext(这一次采样的“世界观”:当前模型信息、对模型可见的工具清单、MCP 绑定、执行环境快照)。步骤快照存在的原因是:轮次中途配置可能变化(模型 fallback、工具动态注册、环境切换),必须保证同一次采样请求中,“上下文里宣称的工具”和“真正能执行的工具”是同一份快照,绝不能出现模型看到了一个工具、调用时却发现不存在的情况。工具系统一章会详细讲这一点。

工具并行、回灌有序。 模型一次回复里可能同时调用多个工具,harness 会把它们全部 spawn 出去并行跑(等待 I/O 的时间互相重叠);但收集结果时严格按模型发出的调用顺序回灌历史。这样既吃到了并发的性能收益,又保证了上下文内容的确定性——同样的历史长出同样的下一步。

停止不只是模型说了算。 模型给出最终回复后,还要跑“停止 hooks”:外部扩展可以在此时投反对票,注入一条“你还有个测试没跑”之类的指令让轮次继续。这给了前端在轮次终点插入策略的能力(呼应第 11 章扩展性)。


1.5 中断:协作式取消,而不是一枪毙掉

用户按下 Esc(或调用中断 Op)时,系统里同时有很多在途的工作:一个可能正在流式传输的模型请求、若干正在跑命令的工具、一个可能正挂起的审批等待、后台的 dev server……强杀整个线程显然是不对的。

Codex 的方案是协作式取消(cooperative cancellation)

flowchart LR
    RT["轮次取消令牌"] --> RS["采样请求取消令牌"]
    RT --> RT2["工具调用取消令牌 ×N"]
    RS --> RS2["流式传输任务"]
    RT2 --> RT3["命令进程等待"]

取消令牌构成一棵树:轮次持有根令牌,每次采样、每个工具调用派生子令牌。中断信号从根令牌发出后沿树传播,所有在途工作各自决定“看到取消后如何尽快体面收场”:模型流停止读取、工具调用提前返回、挂起的等待被放弃。

中断的处理流程是:

  1. 取消令牌触发,状态标记为 Interrupted;
  2. 给运行中的任务一个很短的优雅期(约 100ms)自行收尾;
  3. 超时仍未退出的任务被运行时强制中止;
  4. 向历史追加一条中断标记(“上一轮次被用户中断了”),而不是删掉或改写已产生的内容;
  5. 发出“轮次中止”事件;
  6. 如果此时有“要求触发轮次”的子 agent 来信排队等着,可以自动开一个新轮次处理。

两个语义细节特别能体现设计的克制:

中断不杀后台进程。 通过工具拉起的后台终端(比如 npm run dev)在中断后继续运行——中断的语义是“停止当前 agent 工作”,不是“把这台机器上我启动过的东西都杀光”。清理后台终端是另一个独立的显式操作。

中断不回改历史。 被中断的轮次已经产生的消息、工具调用、工具结果全部保留,只追加一条带标记的说明。模型下一次被唤醒时,会从历史里读到“我上次干到一半被打断了”,从而知道哪些事可能没收尾。这是一条贯穿全局的原则:上下文只追加、不重写。中断因此不是“回滚(rollback)”,而是“翻篇(turn the page)”。第 4 章会看到,这条原则同时服务于缓存效率和可恢复性。

轮次中止有四种原因,语义各不相同:

原因 含义 中断后状态
Interrupted 用户主动中断 Interrupted(可恢复)
BudgetLimited rollout 预算(用量/时长上限)耗尽 Interrupted(可恢复,等用户追加预算)
Replaced 被新轮次取代(旧任务让位) 视情况收尾
ReviewEnded 审查任务随父轮次结束而结束 正常清理

1.6 Steer(转向):模型跑的时候,用户如何插话

场景很常见:agent 正在埋头修一个 bug,用户看着输出突然想起“对了,别忘了顺便更新测试”。如果等它跑完再说,它可能已经按错误方向走了十分钟;如果直接中断,又会丢失当前进度。

Steer 是第三条路:消息既不触发中断,也不被拒绝,而是进入一个待处理输入队列。

它的消费时机经过了精心选择——不在中途打断当前采样,而是在下一个步骤边界被排空

sequenceDiagram
    participant U as 用户
    participant T as 运行中的轮次
    participant M as 模型

    T->>M: 采样请求 #1(进行中)
    U->>T: 插话:"别忘了更新测试"
    Note over T: 进入待处理队列,不打断 #1
    M-->>T: 回复:工具调用
    T->>T: 执行工具,回灌结果
    Note over T: 步骤边界:排空队列<br/>插话写入历史
    T->>M: 采样请求 #2(历史中已包含插话)
    M-->>T: 调整后的行为(去更新测试)

这个设计的考量:

  • 不浪费在途请求。 一次采样已经为上下文付了费(prompt 处理、推理计算),中途打断等于直接扔掉;等它走完这一轮,插话自然出现在下一次请求里。
  • 不破坏上下文一致性。 正在流式接收的回复对应着“旧上下文”,插话属于“新上下文”。在步骤边界注入,保证任何一次采样请求的输入都是自洽的。
  • 对模型就是一条普通消息。 插话进入历史后和用户在轮次开始前说的话没有任何结构上的区别,模型不需要理解“插话”这个概念。

Steer 有明确的边界:只有常规轮次可被插话(审查、压缩任务拒绝插话——它们有自己确定的工作目标,不该被带偏);只接受用户输入;前端还可以指定“只给某个轮次插话”来防止串台。

子 agent 之间的来信走的是同一个队列机制(称为“邮箱”),但多了一个相位控制:如果父 agent 已经输出了用户可见的最终答复,迟到的子 agent 来信就留到下一个轮次再处理,而不是让一个“已经回答完”的轮次又偷偷活过来。第 6 章多 agent 编排会展开讲这套邮箱语义。


1.7 Recover 与 Resume:两种“续上”

“中断后接着跑”在 Codex 里有两个层次,容易混淆,值得分清:

同进程内的恢复(Recover)。 中断后线程仍然活着、历史都在内存里。恢复操作沿用原来的轮次 ID、不带新的用户输入,重新启动一个常规任务。模型从历史里读到中断标记,自行决定从哪里继续。因为轮次 ID 不变,外部观察者看来这是“同一个轮次暂停后又继续”,而不是“新开了一轮”。

跨进程的恢复(Resume)。 进程重启过、内存全空,这时从磁盘上持久化的事件流(rollout)重建整个会话:历史消息、配置、token 用量、中断标记……逐条回放,线程被还原到上次退出时的状态,之后用户可以继续对话。这是第 10 章持久化与可恢复性的主题。

此外还有 Fork(分叉):从某条历史线程的某个点复制/引用出一条新线程,像 git 分支一样并行探索两个方向。根因在于历史是只追加的事件流——“复制一个会话”本质上就是“从某个事件位置开始读同一份流”。

gitGraph
    commit id: "turn 1"
    commit id: "turn 2"
    branch fork
    checkout fork
    commit id: "探索方向 A"
    checkout main
    commit id: "turn 3(方向 B)"

1.8 并发模型总览:串行外壳,并发内核

把运行时的并发策略串起来看,是一张层次分明的图:

graph TD
    subgraph P["进程"]
        TM["线程管理器"]
        subgraph S1["线程 1(串行提交循环)"]
            L1["Op 队列 → 串行分发"]
            T1["活跃轮次 ≤ 1"]
        end
        subgraph S2["线程 2(子 agent,同样串行)"]
            L2["Op 队列 → 串行分发"]
            T2["活跃轮次 ≤ 1"]
        end
    end
    T1 --> P1["采样请求"]
    T1 --> P2["工具调用 1 ∥ 工具调用 2 ∥ 工具调用 3"]
    TM -.->|"全局并发上限<br/>+ spawn 深度限制"| S2
  • Op 处理层:每个线程一个循环任务,严格串行,无锁;
  • 轮次层:同一时刻每个线程至多一个活跃轮次;
  • 步骤内工具层:多个工具调用显式并行,结果按序回灌;
  • 跨线程层:整棵 agent 树有并发轮次上限(超出则排队等待容量),还有 spawn 深度限制防止“子 agent 生孙 agent、孙 agent 又生”的无限繁殖;
  • 空闲唤醒:线程空闲时如果收到“要求触发轮次”的子 agent 来信(或有定时唤醒类任务),运行时会自动开一个新轮次——线程的“睡”与“醒”也是事件驱动的。

这种“外壳绝对串行、内核按需并发”的结构,把并发 bug 的表面积压到了最小:共享可变状态只在串行循环里碰,所有并行任务拿到的都是不可变快照和自己私有的执行上下文。


1.9 小结:这副骨架的四条设计原则

  1. 单循环串行化。 一个线程一个提交循环,Op 排队顺序处理。并发只出现在轮次和工具内部,且拿到的都是冻结快照。状态机因此简单到可以直接画在纸上。

  2. 一切皆消息。 前端与内核之间只有 Op/Event;连“内核问用户要审批”都是发事件、挂起、等应答 Op 唤醒。人机协同、子 agent 通信、扩展回调因此共用同一套机制,没有特例。

  3. 快照驱动。 轮次有轮次快照,每次采样有步骤快照;工具集、环境、配置都在步骤边界冻结。每次请求自洽,也让事后重放成为可能。

  4. 中断是“翻篇”不是“回滚”。 协作式取消沿令牌树传播;历史只追加不重写,中断留下标记;后台进程不受影响;中断是可恢复的非终态。

留给读者思考的几个问题(后续章节会逐一回答):

  • Steer 为什么选择“排队到下个步骤”而不是立即打断采样?这和模型上下文的缓存机制有什么关系?(→ 第 2、4 章)
  • 既然轮次是串行的,为什么工具执行要做成并行?“结果按调用顺序回灌”如果顺序被打乱,会发生什么?(→ 第 5 章)
  • 审批挂起期间,轮次任务一直“停在那里”,它占用了什么资源、释放了什么资源?为什么这不会卡住提交循环?(→ 第 7、8 章)
  • 事件既是给前端看的 UI 数据源,又是状态推导和持久化恢复的依据——一份事件流同时服务三个用途,这对事件的设计提出了什么要求?(→ 第 2、10、12 章)

下一章我们进入交互与事件协议:Op/Event 这层消息总线具体长什么样,模型的流式协议又如何被翻译成前端能消费的事件。

分类:Agent Harness标签:#agent #harness #codex

AI Coding 时代 Code Review 的最终形态

背景

最近半年 Coding Agent 发展很快。SWE-Agent(普林斯顿大学开源的软件工程 Agent)、OpenHands(前身 OpenDevin,开源的自主编程 Agent 平台)、Devin(Cognition AI 推出的 AI 软件工程师)、Claude Code、Codex 这类工具,已经能在几分钟内产出一个可以跑测试的补丁。

这带来了一个很实际的问题:当一个团队每天面对的不是 10 个 PR,而是 1000 个 Agent 提交时,人类 Reviewer 就成了整个系统里最慢的一环。LGTM(Looks Good To Me)从一个判断行为退化成一个盖章行为。

在我看来,这里真正发生变化的不是“Code Review 怎么做得更快”,而是问题本身被重写了。我们都知道,传统 Code Review 建立在一个隐含前提上:代码是稀缺的、写代码本身是瓶颈,所以人有时间逐行看。这个前提在 AI Coding 时代正在崩塌。当代码被大规模、高速度地生成出来,核心矛盾就从 Generate Code 转移到了 Establish Trust(建立可信性)。

那么 Code Review 的未来到底是什么形态?这篇文章简单聊聊我的看法。

为什么说 Verification 比 Coding 更难

在讨论最终形态之前,先看看今天大家在做什么。

目前几乎所有 Coding Agent 的默认闭环都是同一个:

Issue
  |
Coding Agent
  |
生成 Patch
  |
Run Test
  |
失败则继续修改

这里的 verifier(验证者)是 test,也就是“测试通过 = 改动正确”。这个闭环在 benchmark 场景里跑得通,但在真实工程里大概存在这么几个问题:

  • 测试覆盖不全,很多路径根本没有对应用例。
  • 隐藏的 bug、并发问题、内存泄漏、性能回退、安全漏洞,普通单测几乎不可能捕获。
  • benchmark 本身的测试设计也可能有问题——需求描述不完整、断言过严或过松,都会导致 tests pass 并不能可靠地等价于 correctness(正确性)。

OpenAI 最近对 SWE-bench Pro 的审计发现大约 30% 的任务本身是坏的(过严测试、描述不全、低覆盖测试、误导性 prompt),这从另一个角度说明了一件事:在 Agent 时代,验证问题本身比生成问题更难。评测标准里潜藏的噪声,甚至可能让一个“变强的模型”看起来没有进步。

因此,Code Review 需要解决的核心问题从来都不是“更快地看 diff”,而是“用什么方法建立可信性”。这也直接决定了未来的形态不会是一个更聪明的 Reviewer 模型,而是一个由多层验证能力组成的系统。

几个正在发生的方向

业界还没有一个统一的名字(我倾向于叫它 Verification Agent),但多个研究方向已经在向同一个终点收敛。

Test-driven Verifier

这是当下最成熟,也最容易被误解为“已经解决”的方向。它把 Coding Agent 与测试框架绑成一个闭环:改代码、跑测试、失败再改,直到全部通过。前面提到的 SWE-Agent、OpenHands、Devin、Claude Code、Codex,本质都在做这件事。

优点是工程简单、可以做强化学习。缺点也很明确:它把“代码是否正确”等价于“测试是否通过”,把验证问题外包给了测试设计者。当测试不完整时,它会以极高的效率生产出一堆“测试通过但不解决问题”的补丁。

Multi-agent Review

第二类做法是把 Reviewer 也变成 Agent,让不同角色互相 challenge:

Coder Agent
  |
Reviewer Agent
  |
Critic Agent
  |
Fix Agent

Reddit 上有人做过一个非严格的对比实验,在 SWE-bench Verified 的 100 个实例上,单 Agent(Claude Opus 4.5)解决率是 80%,加上一个 Reviewer Agent(GPT-5.2)之后提升到 90%,代价是平均耗时从 3.5 分钟增加到 7.8 分钟,大约 2.2 倍。这个实验样本量不大,不能直接推广,但它揭示了一个更基本的现象:Review 是可以带来独立信息量的,只要 Reviewer 不再和 Coder 共享同一个上下文与偏见。

当然,如果 Reviewer 只是用另一个 LLM 重新读一遍 diff,它仍然停留在“感觉合理”的层面。要往前走,Review 需要能调用工具、能形成证据,而不仅仅是重新表达一次自然语言判断。

Specification Verification

第三个方向是我个人觉得潜在价值最大、但目前几乎没有人做好的。

今天大部分验证做的是 Code -> Pass Test。真正应该做的是 Spec -> Implementation -> Equivalent?,也就是验证实现和规格说明是不是一致。举个例子,一个 PR 的需求可能是:

  • 必须 backward compatible(向后兼容)。
  • P99 延迟必须小于 200ms。
  • 单实例内存必须小于 4GB。

Verification Agent 要做的不是跑一遍单测,而是自动证明:

  • 公共 API 没有破坏性变更。
  • Benchmark 结果满足 P99 阈值。
  • 内存 profile 满足上限。
  • 所有历史测试通过。
  • 新功能测试通过。

这里被 review 的对象已经不再是代码本身,而是 Spec 是否被实现。这一步之所以难,是因为它要求需求本身是可验证的——这大概也意味着未来工程流程里,Spec 会重新变成一等公民。

Formal Verification 与 Explain + Verify

第四个方向来自更“硬核”的一侧。

近期有一篇论文叫 “The Prover Is the Judge”,尝试用 Ada / SPARK(一种面向高可靠系统的编程语言与验证工具集)和 GNATprove(SPARK 的形式化证明工具)让 AI 生成安全关键代码,再由证明器验证大量证明义务,形成“AI 写代码,证明器判决”的闭环。论文里报告 GNATprove 自动完成了 49,280 条证明义务,覆盖了密码学、TLS 1.3、IKEv2、X.509 等组件,但作者也坦率地指出:证明器能证明什么,取决于规格和验证器的表达能力,对于某些缺陷仍然需要 known-answer tests(已知答案测试)、互操作验证或人工审查。

与之并行的是 Explain + Verify 这条思路,代表性的评测是 ExplainBench(ASE 2026)。它的观察很朴素:一个 Agent 说“我改好了”并不足够,我们真正想知道的是“为什么这样改、影响哪些模块、为什么不会 break”。ExplainBench 的结果给出一个重要判断:解释质量与 Coding 能力是两个独立维度,一个补丁可能正确但解释错误,反之亦然;而且 Agent 经常错误地宣称补丁正确。论文里实现了一个 explanation audit agent(解释审计代理),通过额外运行测试来验证和修正解释,确实改善了所有被评估 Agent 的解释质量。

六层 Verification Stack

把上面这些方向叠加起来,我倾向于认为 AI Coding 时代的 Code Review 最终会长成一个分层的 Verification Stack,而不是任何单一模型或单一工具。下面简单介绍一下每一层。

Layer 1:Syntax

最底层,也是最容易被忽视的一层。编译、lint、格式化、类型检查在这一层完成。它保证“代码至少是一个合法的字符串”,任何上层验证都建立在这个前提之上。AI 在这一层的价值不大,现有工具已经足够。

Layer 2:Static Analysis

第二层负责结构性缺陷:死锁、竞态、资源泄漏、空指针、注入类漏洞等等。今天已经有 CodeQL(GitHub 的语义代码分析引擎)、Semgrep(轻量级静态分析工具)、Infer(Facebook 开源的静态分析器)等成熟工具。未来 LLM 的角色大概是统一调用与解释:根据改动特征选择合适的规则集,把工具产出翻译成可读的、能被 Verification Agent 引用的证据。

Layer 3:Behavioral Verification

第三层不再看代码,而是运行代码:unit test(单元测试)、integration test(集成测试)、e2e(端到端测试)、fuzz(模糊测试)、mutation test(变异测试,通过故意修改代码来检验测试用例的质量)。AI 在这一层最有价值的动作是自动补测试——针对 diff 与调用图,生成有针对性的用例,特别是补齐 mutation score(变异得分)较低的路径。它是 Test-driven Verifier 的自然升级。

Layer 4:Repository-level Reasoning

第四层是 LLM 相对于传统工具真正拉开差距的地方。它要回答的是仓库级问题:

  • 这个 PR 影响哪些模块?
  • 有没有引入重复实现?
  • 有没有违反既定架构?
  • 有没有造成 API drift(API 漂移)?

这一层需要基于代码图(调用图、依赖图、模块图)做推理,是 diff-only Reviewer 天然做不到的。它决定了 Verification Agent 能不能看到“小 PR 引发系统性风险”的场景。

Layer 5:Spec Verification

第五层是我认为价值最大、也最难的一层。它把需求变成一等公民:

Spec
  |
读取代码
  |
生成验证计划
  |
执行验证
  |
证明是否满足

在这里被 review 的不再是 code,而是 intent(意图)。这一层的成熟度大概直接决定了未来能不能把 Coding Agent 真正规模化地交给系统而不是个人。

Layer 6:Runtime Verification

最后一层很多人会漏掉:不是所有问题都可以在合入前发现。内存泄漏、突发流量、少见的竞态、上线后的延迟回退,本质上都是运行时问题。未来的 Verification Agent 会延伸到 merge 之后:持续观察 metrics、logs、trace、crash、canary(金丝雀发布),判断某个指标漂移是不是某个 PR 引起,必要时触发自动回滚。

也就是说,Verification 不是一个 merge 前的门禁,而是覆盖整个软件生命周期的能力。

未来流程

如果把这些层组合起来,未来的流程大概会更像这样:

Spec
  |
Coding Agent
  |
Verification Agent
  ├── Static
  ├── Dynamic
  ├── Tests
  ├── Security
  ├── Performance
  ├── Spec Matching
  ├── Explanation Audit
  └── Runtime Prediction
  |
Risk Score
  |
Human(仅高风险)

对比今天的 Human -> Read Diff -> Approve,可以看到两个主要差别:

  • Human Review 不消失,但被显式地留在高风险位置。绝大多数改动由 Verification Agent 直接通过或直接拒绝,人只在系统“拿不准”的时候进入。
  • Verification 的输出从“同意 / 拒绝”变成“一个带有置信度的风险评分”,可以被后续系统消费。

这两个变化共同推翻了 Code Review 的旧范式:Reviewer 不再是一个决定权在自己手上的角色,而是整个可信性系统里的一环。

Evidence-based Verification

如果只让我押一个具体的研究方向,我会选一个几乎还没有名字的方向:Evidence-based Verification Agent(基于证据的验证代理)。

今天的 Reviewer(无论人还是 AI)给出的通常只是一个结论:

LGTM

未来的 Verification Agent 应该给出的是一整条证据链:

Claim:
  这个 PR 不会 break API。

Evidence:
  - AST Analysis:公共接口签名未变。
  - 142 Regression Tests:全部通过。
  - Mutation Score:93%,覆盖新增分支。
  - Call Graph:无新增循环依赖。
  - Canary Prediction:预测风险 0.03。

Confidence:
  98.7%

这个方向和近几年 AI 领域强调的 reasoning trace(推理轨迹)有相似的气味,但对象完全不同:reasoning trace 是模型推理过程的可解释性,而 Evidence Chain(证据链)是软件可信性的可解释性。前者服务于“模型说了什么”,后者服务于“我们应不应该相信这次改动”。

顺着这个方向再往下想,有三个问题在我看来最值得投入:

  • Spec-to-Evidence Verification:如何把自然语言需求自动转化为可验证的证据计划,而不仅仅是生成一些测试。
  • Repository-scale Verification Graph:把整个仓库建模成依赖图、调用图与架构图,验证一次变更的系统级影响,而不是只看 PR diff。
  • Evidence Aggregation:把静态分析、动态测试、LLM 推理、运行时监控、形式化验证等信号融合成一个可解释、可校准的风险评分,而不是简单的 Approve / Reject。

如果说 Coding Agent 的核心动作是 Generation(生成),那么 Verification Agent 的核心动作就应该是 Evidence(证据)。Code Review 这个名字大概率会保留下来,但它背后的东西会被彻底换掉——不是“怎么看代码”,而是“我们凭什么相信一段代码”。这大概也是 AI Coding 时代最值得做的一件事。

参考

分类:ai标签:#ai-coding #code-review #verification-agent #agent #software-engineering

一次网络延迟高的问题排查

问题背景

客户的机房部署了多套不同架构的 k8s 集群:Intel CPU + redhat OS,海光 CPU + 麒麟 OS。其中 Intel CPU + redhat OS 的 k8s 已经稳定运行了很久。海光 CPU + 麒麟 OS 是后来上线的,主要是为了满足信创的要求。但是在实际使用中发现,海光 CPU + 麒麟 OS 的 k8s 集群中,运行的服务在压测时会偶现服务请求 redis 超时的异常。

问题定位

因为同样的服务,在非信创的机器上可以正常运行,因此排除服务本身的问题。主要排查硬件+操作系统+ k8s CNI 这几个环节。这里之所以要排查 CNI 的问题,主要还是考虑这个 CNI 是专门为这个客户开发的,用来实现 Underlay 的网络,以及一系列的特殊网络需求。 整个网络的链路如图所示: network1

抓包分析

首先是在 Pod 内用 tcpdump 进行长时间的抓包,保存的本地文件。然后进行压测来复现问题。根据日志找到出问题的时间点,用 wireshark 自带的命令对抓包文件按时间切割。

# 每 10s 保存一个分片。
editcap -i 10 tcpdump.cap pod1111

用 wireshark 打开对应时间片段的抓包文件进行分析。这里因为安全要求不方便放上异常包的截图。大概描述一下问题现象:分析的是 tcp 包,表现为异常时间点 redis 回包存在大量重传,且重传的包几乎都在同一时刻到达 Pod 内。 因此下一步要排查 tcp 包的延迟发生链路上的什么位置。因此选择同时在 redis 虚拟机,物理机网卡 eth0,pod 内网卡抓包。然后用同样的方式进行问题复现。 因为有了多个位置点的抓包数据,根据 tcp 的 seq 号就可以分析同一个数据包从 redis 虚拟机发出来的时间,以及到达物理机 eth0 以及 Pod 内的时间。然后根据时间就可以找到延迟点在哪。 分析后发现延迟在 redis→物理机这条链路上。redis 出来的包因为延迟到达了物理机,因此也延迟到达 Pod 内。redis 所在的虚拟机发包后因为一直没有收到回报,因此会触发 tcp 的重传机制。但是重传的包也出现了延迟。最终原始包和重传包在某一刻同时进入了物理机。 因此怀疑是交换机上出现了延迟,但是如果是交换机的问题,那么接入该交换机的其他机器也一定会出问题才对。但是现象仅局限于信创服务器上。所以也和麒麟的供应商沟通了,他们怀疑是一个已知的 cgroup 问题导致的,出现在 4.19 前的内核里。升级内核后果然问题就解决了。

问题分析

上面说的 cgroup 问题,详细来说是 kubelet 中的 cadvisor 在采集 node 的内存信息时,会读取 /sys/fs/cgroup/memory/memory.numa_stat 信息。但是因为内核的实现会导致这个读取信息的系统调用很慢。慢的原因有两点:

  1. cgroup 是通过 cgroup 伪文件系统来管理的,可以通过删除伪文件系统中的文件目录来删除相应的 cgroup。但是内核中代表 cgroup 的结构体会仍然存在,直到所有对它的引用被释放。只有当被删除的 memory cgroup 中的页都被回收掉,相应的引用都被释放,该 memory cgroup 才会被彻底删除。系统中所有的 memory cgroup 数量可以通过 cat /proc/cgroups 来查看。而内存页的回收时间与内核的回收机制有关,如果当中有一些页一直活跃的被使用,就可能永远不会被回收。
  2. cadvisor 读取 /sys/fs/cgroup/memory/memory.numa_stat 信息时,其实是一个系统调用。这个调用的实现也存在性能问题,它会遍历所有的子 cgroup 层级,累加 memory 的使用信息求和,得到总的 memory 使用情况。

因此,在一台一直运行的服务器上,memory cgroup 可能会达到 1w+。cadvisor 在获取 memory cgroup 时可能耗费 1s 以上的时间。在这段时间内,CPU 没法被调度给其他地方使用。 那为什么会导致网络的延迟呢?linux 网络数据包的接收,在之前的文章 linux 网络数据包接收流程(一)中整理过。数据包到达网卡后,依赖硬中断(3)+软中断(6)来触发 CPU 对数据包进行处理。 network2 并且现在的网卡很多都是多队列的,每条队列和某个 CPU 进行绑定,由该 CPU 进行处理。因此如果这个软中断发生在 cadvisor 统计 memory cgroup,进行系统调用时,软中断的处理就可能因此而延迟。如果这个过程持续 1s+,那么引起的现象可能就是对端 tcp 出现重传。如果这个过程持续 2s+,那么因为服务本身读取 redis 的超时时间设置为 2s,就可能出现超时了。

解决方案

长期解决方案就是升级内核。在更高的版本内核中,对 cgroup memory 的计算进行了优化,这里不再会遍历所有的子 memory cgroup 进行统计了。因为本身 cgroup 就已经维护了该信息,直接读取并返回就行了。内核相关修复:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?h=v6.2-rc7&id=dd8657b6c1cb5e65b13445b4a038736e81cf80ea 短期解决方案是周期执行这条命令:echo 3 > /proc/sys/vm/drop_caches。这会触发内核清理 PageCache, dentries 和 inodes 缓存。这里如果是 echo 1,则只清理 PageCache。echo 2,只清理 dentries 和 inodes 缓存。

分类:linux

runc 的输入输出

概述

runc 是一个基于 OCI 规范实现的程序。在基于 containerd 的容器技术栈上,runc 是一个非常重要但又容易忽略的组件。当执行 docker, nerdctl, ctr 等命令时,其实底层都要调用 runc 去运行容器的进程。不过这篇文章仅会涉及到 runc 的输入输出。 因为在日常的开发中,大多数开发者接触到的都是 docker,因此下面会以 docker 为例,结合底层的 runc 来说明容器进程的输入输出。

标准输入输出

在 linux 上,所有的进程都会打开三个文件描述符:

  • 0: stdin 标准输入
  • 1: stdout 标准输出
  • 2: stderr 标准错误输出

在容器技术上,同样离不开这三个基本概念。在使用 docker run/exec 时,其实就是在 linux cgroup 和 namespace 下运行了一个普通的进程而已。所以这样的进程同样会有输入输出,当我们使用 docker run nginx 的时候,nginx 的日志就会打印到终端上,其实就是将 nginx 进程的标准输入输出通过各种手段输出到终端上了。

-i 参数

docker 提供了 -i 参数,来运行交互式的命令,文档上的说明是:

-i,  --interactive                    Keep STDIN open even if not attached

也就是说即使没有使用 docker attach,也会保持 STDIN 打开。这样我们在终端的 STDIN 就会定向到容器内 sh 进程的 STDIN,这样就可以在终端上输入命令行了。 比如 docker run -i nginx sh。这里就是使用 nginx 镜像启动容器,并运行了 sh 命令,然后终端的 bash 的 STDIN 定向到容器内的 sh。如下图所示: /uploads/wp/2023/03/Snipaste_2023-03-08_13-01-18.png 不过我们也可以发现,这里的输出格式似乎和在终端里 ls 的输出不同。这里就要引入一个新的知识点:tty 与 pty 以及我们的 -t 参数。

-t 参数

官方文档上的说明是:

-t, --tty                            Allocate a pseudo-TTY

tty 得名于早期的电传打字机(Teletype),随着技术的发展,tty 的概念变得更宽泛,它不再指打字机这样的物理设备,很多时候指的是 linux 内核中的 tty 驱动。当我们使用无 GUI 的 linux 时,比如 server 版本的 ubuntu,我们使用 ctrl+alt+f1~f6就可以在 tty1~6 之间进行切换。这里的 tty 就是由 linux 内核模拟出来的。 /uploads/wp/2023/03/Snipaste_2023-03-08_13-18-38.png 不过在 GUI 场景中,我们打开的终端并不是使用 tty,而是 pseudo-TTY(下文称之为 pty)。pty 也称为伪终端,也就是说它并不是真实的 tty 设备。引入了 pty 之后,终端的数量就不会再有限制了。我们可以打开任意多的 GUI 终端程序,每打开一个 GUI 终端,就会在 /dev/pts/ 下生成一个数字的文件,这个文件就是 pty slave,另外全局还有共用的一个 pty master,位于 /dev/ptmx。

  1. 通过 GUI 终端,我们可以输入字符,输入的字符会发到 pty master 上,pty master 文件描述符,发送到对应的 pty slave。
  2. pty slave 将输入发送给终端上的 shell 程序,比如 bash。
  3. bash 上执行的任何命令,都会将输出发送给 bash,bash 再发送给 pty slave,之后再经过 tty driver, pty master 到 gui 终端上显示。

pty 的工作原理如下图: /uploads/wp/2023/03/Snipaste_2023-03-08_13-28-22.png 这时候我们在回到 -t 参数,这里其实就是为容器内的 sh 进程分配了一个 pty slave。当 sh 感知到自己被分配了 pty slave 后,它会采用不同的工作模式。比如会在终端上输出提示符,对输出的格式进行调整,像 bash 之类的还是加上颜色等等。 Untitled

# 下面这段程序会输出很多行,也说明了在 sh 里执行 ls 时,原本的输出是很多行的。
root@5f96c3475e20:/# ls | while read line; do echo $line;done
bin
boot
dev
docker-entrypoint.d
docker-entrypoint.sh
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

回到 runc

上述提到 stdio,pty 等概念,很好的解决了运行容器时的输入输出的处理。而这些能力也是 runc 本身就提供的,docker 是在其之上进行了封装。 runc 的输入输出处理有四种模式,由 -d(detach),-t 进行组合得到。

terminal && detached

/uploads/wp/2023/03/Snipaste_2023-03-08_13-44-19.png

  1. 上层的管控程序,比如 runc-shim 会创建 unix socket
  2. runc 运行在容器内,接管容器的 stdio。然后通过 unix socket 将 pty master 和 fd 发送给上层管控程序。
  3. 将自己的 stdio 通过 pty slave 输入输出。

passthrough && detached

/uploads/wp/2023/03/Snipaste_2023-03-08_14-02-40.png

passthrough && foreground

/uploads/wp/2023/03/Snipaste_2023-03-08_14-03-28.png

terminal && foreground

/uploads/wp/2023/03/Snipaste_2023-03-08_14-03-22.png

参考资料

http://www.wowotech.net/tty_framework/tty_concept.html http://www.wowotech.net/tty_framework/tty_architecture.html http://www.wowotech.net/tty_framework/application_view.html https://blog.51cto.com/u_14592069/5824829 https://dev.to/napicella/linux-terminals-tty-pty-and-shell-192e https://github.com/opencontainers/runc/blob/main/docs/terminals.md

分类:容器技术

kubelet PLEG 的实现与优化

概述

PLEG 全称是 Pod Lifecycle Event Generator,用来为 kubelet 生成 container runtime 的 pod 生命周期事件,这样 kubelet 就可以根据 pod 的 spec 和 status 对比,来执行对应的控制逻辑。 在 1.1 及之前的 kubelet 中是没有 PLEG 的实现的。kubelet 会为每个 pod 单独启动一个 worker,这个 worker 负责向 container runtime 查询该 pod 对应的 sandbox 和 container 的状态,并进行状态同步逻辑的执行。这种 one worker per pod 的 polling 模型给 kubelet 带来了较大的性能损耗。即使这个 pod 没有任何的状态变化,也要不停的对 container runtime 进行主动查询。 因此在 1.2 中,kubelet 引入了 PLEG,将所有 container runtime 上 sandbox 和 container 的状态变化事件统一到 PLEG 这个单独的组件中,实现了 one worker all pods。这种实现相比于 one worker per pod 已经带来了较大的性能提升,详细实现会在后文进行介绍。但是默认情况下,仍然需要每秒一次的主动向 container runtime 查询,在 node 负载很高的情况下,依然会有一定的性能问题,比较常见的情况是导致 node not ready,错误原因是 PLEG is not healthy。 在 1.26 中,kubelet 引入了 Evented PLEG,为了和之前的 PLEG 实现区别,之前的 PLEG 称为 Generic PLEG。当然,Evented PLEG 并不是为了取代 Generic PLEG,而是和 Generic PLEG 配合,降低 Generic PLEG 的 polling 频率,从而提高性能的同时,也能保证实时性。

Generic PLEG

Generic PLEG 定时(默认1s)向 runtime 进行查询,这个过程称为 relist,这里会调用 cri 的 ListPodSandboxListContainers接口。runtime 返回所有的数据之后,PLEG 会根据 sandbox 和 container 上的数据,对应的 Pod 上,并更新到缓存中。同时,组装成事件向 PLEG Channel 发送。 /uploads/wp/2023/02/Snipaste_2023-02-27_16-10-20.png kubelet 会在 pod sync loop 中监听 PLEG Channel,从而针对状态变化执行相应的逻辑,来尽量保证 pod spec 和 status 的一致。

Evented PLEG

引入 Evented PLEG 后,对 Generic PLEG 做了些许调整,主要是 relist 的周期和阈值,以及对缓存的更新策略。

  • relist 的同步周期由 1s 增加到 300s。同步阈值从 3min 增加到 10min。
  • 缓存更新时,updateTime 不再是取本地的时间,而是 runtime 返回的时间。

除此之外,Generic PLEG 会和之前一样运行,这样也保证了及时 Evented PLEG 丢失了一些 状态变更的 event,也可以由 Generic PLEG 兜底。 Evented PLEG 会调用 runtime 的 GetContainerEvents 来监听 runtime 中的事件,然后生成 pod 的 event,并发送到 PLEG Channel 中供 kubelet pod sync loop 消费。 如果 Evented 不能按照预期工作(比如 runtime 不支持 GetContainerEvents),还会降级到 Generic PLEG。降级逻辑是:

  • 停止自己。
  • 停止已有的 Generic PLEG。
  • 更新 Generic PLEG 的 relist 周期和阈值为 1s, 3min。
  • 启动新的 Generic PLEG。

/uploads/wp/2023/02/Snipaste_2023-02-27_16-58-56.png /uploads/wp/2023/02/Snipaste_2023-02-27_16-10-20-1.png 因为 Evented PLEG 和 Generic PLEG 会同时更新缓存,所以在更新时还会对比当前值和缓存值的时间戳,保证当前值是更新的状态,才会更新到缓存中。

参考文章

分类:k8s标签:#pleg

Traceroute 的实现原理

traceroute 是一个很常用的工具,用来检查当前设备到目的 IP 地址的路径以及每个中间设备产生的延迟。如下图所示:

traceroute to baidu.com (110.242.68.66), 64 hops max, 52 byte packets
 1  10.43.244.2 (10.43.244.2)  4.132 ms  2.294 ms  2.683 ms
 2  10.41.0.217 (10.41.0.217)  3.178 ms  1.846 ms  1.686 ms
 3  10.40.0.54 (10.40.0.54)  2.554 ms  2.033 ms  2.174 ms
 4  10.42.0.69 (10.42.0.69)  4.058 ms  2.905 ms  2.957 ms
 5  10.42.0.54 (10.42.0.54)  3.304 ms  3.058 ms *
 6  10.42.0.20 (10.42.0.20)  3.205 ms  3.199 ms  3.086 ms
 7  14.17.22.130 (14.17.22.130)  4.497 ms  3.424 ms  3.195 ms
 8  10.162.89.97 (10.162.89.97)  10.903 ms  4.797 ms  4.136 ms
 9  10.200.52.57 (10.200.52.57)  5.684 ms
    10.200.52.65 (10.200.52.65)  5.288 ms
    10.200.52.73 (10.200.52.73)  7.327 ms
10  * * *
11  * * *

在 mac/linux/windows 下都有类似的工具。因为在 https://github.com/joyme123/gnt 中实现了 traceroute 的能力,这里记录一下。 traceroute 的实现中,利用了一些基本的网络协议的特性:

  • IP (网络层)数据包在网络中传输时,可以通过 TTL 字段来控制这个数据包的生命周期。每经过一个三层设备的转发,这个 TTL 都会减少1,当 TTL 变为 0 时,三层设备就会丢弃这个数据包而不是继续转发它。这样的设计可以避免网络链路形成环时,数据包会被无限的转发。
  • 中间的三层设备在丢弃数据包时,会使用 ICMP 协议,向数据包的源发送方(通过 src ip)发送 ICMP 包,来告知数据包因为 TTL 为 0 而被丢弃。当然有的三层设备不会发送这个 ICMP 消息,所以 traceroute 时部分中间环节会显示 *。并且这种 ICMP 包的 payload 都会包含源数据包的二层和三层 header。
  • traceroute 支持多种协议: TCP、UDP 和 ICMP。当 TCP, UDP 的包到达目标设备后,如果 TCP, UDP 的目的端口不能访问,那么目标设备也会通过 ICMP 消息向源设备告知该端口不可达。如果是使用 ICMP echo request,那么目标设备会返回 ICMP echo reply 向源设备告知收到了 echo request。

有了以上的网络协议特性,traceroute 的实现可行性就有了。以使用 TCP 协议为例,具体的步骤如下:

  1. traceroute 构造 TCP 数据,源端口根据当前进程 ID 生成,目的端口选择一个不太可能使用的端口,比如 33434。
    1. 源端口根据当前进程 ID 生成,这样收到中间网络设备的 ICMP 包时,就可以通过 payload 中携带的源数据包信息判断出这个 ICMP 是响应哪个进程的。
    2. 目的端口选择一个不常用端口,是防止对目的端的 TCP 服务产生影响。并且这个目的端口每次请求都会加1。
  2. traceroute 构建 IP 头,TTL 一开始设置为 1。这样第一个中间设备收到后,就会丢弃这个包,并返回 ICMP 包了。后续 TTL 逐渐加 1,就可以探测到每一个中间设备了。
  3. 当 traceroute 收到 ICMP 包时,先根据源端口判断这个包属不属于当前进程,再根据目的端口判断这个包是第几个发出的。比如目的端口是 33436,那么已知起始目的端口是 33034 的情况下,就知道这个包是第三个发出的。这样根据第三个包发出的时间,就知道延迟情况了。
    1. 如果这个 ICMP 包是 ttl exceeded,说明中间网络设备返回的。
    2. 如果这个 ICMP 包是 destination unreachable,说明是目的设备返回的。
分类:网络

Ping 与 ICMP 协议

概述

ping 命令是一个非常常用的网络工具,通过 ICMP 协议来探测本地到远端地址之间网络的连通性,以及延迟,稳定等性能指标。但是大多数人其实对 ping 命令的实现了解的并不会太多,因为我们日常的开发工作中,很少会和 ICMP 协议打交道。因为最近在开发 https://github.com/joyme123/gnt,目标是通过单个二进制文件,实现大多数的网络工具的能力。所以接触了一些 ping 命令的实现,这里做一些简单的记录和分享。

ping 命令基于 ICMP 协议的实现

ICMP 协议本身这里不多做介绍,网络上有很多很好很详细的资料。比如维基百科上的这篇介绍:Internet_Control_Message_Protocol。 ICMP 和 TCP/UDP 这样的协议有这很大的区别,像 TCP/UDP 这种传输层的协议,都是进程级别的,即可以通过 Port 对应到一个或多个进程。因此在运行使用 TCP/UDP 协议的应用时不需要特殊的权限。而 ICMP 则不一样,它是操作系统级别的,因此早期的 linux 上,如果应用要发送 ICMP 包,则必须通过 socket(AF_INET, SOCK_RAW, int protocol) 这种方式来实现,而调用 SOCK_RAW 则需要 root 权限,或者通过 linux network capability。 因为这种特殊性,后来的 linux 又提供了 socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) 这种方式去发送 ICMP,这里的 SOCK_DGRAM 是 UDP 协议使用的。不过容易误解的是,并不是说用 UDP 协议去包装或实现了 ICMP 的能力,使用这种方式发送出去的仍然是 ICMP 数据包,不过不再需要 root 权限或者特殊的 network capability 设置了。通常称这种方式为 Unprivileged ICMP。linux 同样也提供了 sysctl 的配置去限制这一能力的使用

# 999~59999 指定了允许的用户组 ID 范围,如果所有用户组都不允许可以设置为 1 0
net.ipv4.ping_group_range = 999 59999

不过需要注意的是,通过 SOCK_DGRAM 只能发送这几种 ICMP 请求:ICMP_ECHO, ICMP_TSTAMP or ICMP_MASKREQ. 解决了如何发送 icmp 包的问题,就可以考虑实现几个主要的 ping 命令特性了。

丢包检测

每个 ICMP 包都有一个 sequence 字段,发送的时候可以指定这个 sequence 的值,目的端响应的时候会把这个 sequence 值设置成一样的,表示响应的是哪一个请求包。这样我们就可以知道每个发送出去的 ICMP 的响应包了,那么没有响应的 sequence 就是被丢弃的包。通过这种方式就可以检测出网络中使用存在丢包现象。

延迟检测

ICMP 支持 echo request 和 reply,即通过 ICMP 协议包装的 payload 发送出去,目的端会原样返回。所以我们可以通过在 request 的时候写入发送时的时间,然后收到回包时取出这个时间就能知道延迟了。 关于写入的时间格式,实现方式上可以随意。但是建议使用 unix time,精确到微秒。然后保留 4 字节的秒+4字节的微秒,写入到 payload 的开头。这样的实现方式和 linux ping 的实现一致,像 wireshark 这种抓包工具就可以识别出来了。

分类:网络