跳到主要内容

deepseek-harness — 本课题摘录

读了哪几篇: 02-session-log(唯一事实源与投影)、03-agent-loop(turn/step 状态机)、04-tools(工具注册表与执行流水线)。 其余三篇(插件树、能力接缝、每会话组装)本轮没读。

这一家在本课题里的位置:它对"一轮的状态放哪"给了最彻底的答案——不放任何地方,只放一条日志,其余全是它的投影。

它对本课题回答了什么

决定一最重要的一条:取消"同步",只留一份事实

它先把问题说清楚:agent 的"记忆"如果由多个地方各自维护,早晚会对不上。

朴素实现通常有三份状态:内存里的消息数组、界面上显示的对话、磁盘上的存档。靠"每次都记得同步一下"保持一致。只要有一处忘了同步,就出三类经典事故:

事故表现
界面有、模型没有用户看到一条注入的上下文,模型请求里却没带上
模型有、存档没有进程崩溃后恢复,模型突然"忘了"上一轮做过什么
压缩把历史改坏了摘要覆盖了原文,界面上用户已经读过的内容凭空消失

(依据:Agent 库 · DeepSeek Harness · 第 2 章 · 唯一事实源:append-only 会话事件日志与 surface 投影 —— 朴素实现有内存 messages、UI 对话、磁盘存档三份状态靠同步维持一致,会出三类事故——界面有模型没有、模型有存档没有、压缩把历史改坏;dsh 的办法是取消同步,只留一份事实其余全是投影)

它的办法是取消同步这件事:只留一份事实,其余全部是它的投影。

一条可执行的硬规则

模型可见 ⟺ 已落账:任何能进入一次模型请求的东西,都必须能从会话日志重建;新增一种模型可见输入,就必须新增一种会话事件。

而且它在代码里有强制检查点,不是口号。 (依据:Agent 库 · DeepSeek Harness · 第 2 章 · 唯一事实源:append-only 会话事件日志与 surface 投影 —— 规则「model-visible ⟺ logged」——任何能进入模型请求的东西都必须能从会话日志重建,新增一种模型可见输入就必须新增一种会话事件;代码里有强制检查点)

三个承重词各有唯一含义,不互相借用:

指什么能不能改
日志一次会话发生过的全部事实,按序号连续编号只能在尾部追加;已写入的事件深度冻结
可见面日志里"会变成一条模型消息"的那些事件的有序编号列表可以遮蔽某一段,但不动日志
投影把可见面的每个节点算成一条消息的纯函数结果缓存产物,随时可从日志重算

它的类比很好:日志是记账凭证(一张都不能撕),可见面是当期科目余额表(可以把一批旧凭证结转掉),投影是打印出来的报表。

"压缩不是删历史,是在可见面里遮蔽一段"——这是本课题"历史怎么压"这一支最干净的表述。 deepagents 的"只做投影"、nanobot 的"只修副本"、opencode 的"库里还在只是不发"都是同一族; 这一家把它上升成了数据模型,而不是一个技巧。

决定四:turn 和 step 两级,定义写得最清楚

step(步)= 一次模型请求 + 这次请求叫起来的那批工具。 turn(轮)= 从"认领一条输入"到"没人再欠东西"之间的所有 step。

(依据:Agent 库 · DeepSeek Harness · 第 3 章 · 主循环:turn / step 状态机、Inbox 与一次模型请求的生死 —— step 是一次模型请求加这次请求叫起来的那批工具,turn 是从「认领一条输入」到「没人再欠东西」之间的所有 step;一个 turn 里可以有 0 个也可以有 10 个 step)

一个轮里可以有 0 个步,也可以有 10 个。

而且驱动器自己几乎不存状态:每一次模型请求都从会话日志现场重建。

跟 codex 的"回合 / 采样"、pi 的"外层 / 内层"是同一个两级结构的三种命名。 这一家的定义最精确,因为它把"没人再欠东西"作为轮的结束判据—— 而不是"模型不再要工具"。 两者的差别在于:还有别的东西可能欠着(排队的输入、未完成的工具)。

待办输入是一个"收件箱",而且它也是日志事件的投影——不是另一个内存队列。

决定三:工具的执行函数不返回给模型看的文本

这是它和多数实现最不一样的一处。

工具的执行只返回一个规范 JSON 值,再由三个纯函数分别投影给三个消费者:

执行返回一个 JSON 值

├─► 先按声明的格式校验,不合格直接报错

├─► 「渲染」函数 → 格式化文本 → 模型看这个
├─► 「呈现元数据」函数 → 结构化数据 → 界面看这个(仅顶层调用才算)
└─► 原始 JSON 值 → Code Mode 里的程序看这个

(依据:Agent 库 · DeepSeek Harness · 第 4 章 · 手脚:工具注册表、三段式执行流水线与 Code Mode —— 工具的 execute 不返回给模型看的文本、只返回规范 JSON 值,再经 output.schema 校验后由 render 投影给模型、presentationMeta 投影给 UI(仅顶层调用才算)、原始 value 给 Code Mode 里的程序)

它给的理由:同一个真实结果要喂三张嘴,而三张嘴要的东西不一样。

这条比 griptape 的"信封"更进一步: 信封说的是"数据要有类型";这一家说的是"同一份数据要有多个投影,而且投影必须是纯函数,回放时能重算"。 对我们的意义:工具返回值不该直接是"给模型看的字符串"。

校验有三条纪律:先校验再投影;渲染函数必须是纯投影(只读参数和已冻结的值);界面用的元数据只给顶层调用算。

决定二:参数格式是"编译"出来的,不是手写的

作者不直接写 JSON Schema,而是写一份更窄的 DSL,编译成 JSON Schema。

收窄的理由有两条,都很实在:

理由说的是什么
这份格式说明要同时能编译成 TypeScript 类型和 Python 类型全量 JSON Schema 做不到
注册表要用同一套规则校验返回值支持越多,校验器越可能有洞

(依据:Agent 库 · DeepSeek Harness · 第 4 章 · 手脚:工具注册表、三段式执行流水线与 Code Mode —— 参数 schema 由一份更窄的 DSL 编译成 JSON Schema,收窄的两条理由是——要同时能编译成 TypeScript 类型与 Python TypedDict(全 JSON Schema 做不到)、注册表要用同一套规则校验返回值(支持越多校验器越可能有洞))

编译器用显式任务栈代替递归下降,深层嵌套不会爆栈,同时用一个集合检出循环引用。

Code Mode:把"调 20 次工具"压成"跑一段程序"

把整张工具表渲染成一份 SDK,让模型写一段程序批量调度。

关键在回填:子调用逐条落账,但只有外层结果进模型历史。

这是"结果怎么回填"的一个极巧的答案: 二十次工具调用的中间结果模型根本看不到,它只看到程序的返回值。 上下文占用从二十份结果压成了一份。 代价:模型看不到中间过程,出错时不好定位。

它没回答什么

  • 停止条件的护栏——这三篇讲结构,没讲轮数上限、连错熔断这些。
  • 不用原生工具调用怎么办——它假设模型支持。

坑与代价

  • "只留一份事实"的代价是每次都要投影。 投影是缓存产物,但缓存失效时要从日志重算——长会话里这本身是开销。
  • "模型可见即已落账"这条规则很硬,但也意味着扩展成本高: 想让模型多看见一种东西,必须先给日志加一种事件类型。

    判断(无锚): 这条规则值得从第一天就守。我们的最小原型只要一条追加式的事件列表,把"发给模型的"从它投影出来,成本几乎为零。 如果错,会错在: 如果第一版的"历史"就是一个消息数组、也不做界面、也不需要回放,那"日志 + 投影"就是纯粹的多一层。

  • Code Mode 让模型看不到中间过程。 省上下文,但调试和纠错都变难