学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 03
第 4 章 · EP 03

Model-visible ⟺ logged:一条会崩给你看的不变量 等 2 节

时长 15:44音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 两份状态迟早漂移1:18
1:18只留一份真相2:27
3:46验不过就崩2:18
6:05一份日志买到全家桶1:53
7:59插话要分三种2:51
10:51取消息要用领取制1:45
12:36中断之后的唤醒要降级1:38
14:14可带走的原则1:29
解读全文

ep35 · Model-visible ⟺ logged:一条会崩给你看的不变量 等 2 节

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
来源:xueai.miyang.cn(小山学堂 · 洛小山)

本集为两节合辑(素材 8335 字),已做取舍。

统一主线:两节都在回答同一个问题 —— 新进来的东西,什么时候算数。

节问题答案
不变量一条内容进模型之前必须先成为一条事件
双队列一条消息进循环之前必须先决定它属于哪一班

一句话速览

模型看到的一切都要能从日志重建,发请求前还要现场验一遍,验不过直接崩。Agent 正在干活时你想说句话,消息该排哪条队、什么时候被处理 —— 三个 API 共用一个 send(),只差两个参数。


能力地图

第一节 · 单一真相

概念含义
会话日志只追加的事件流,是唯一真相
消息历史从日志派生,"从不单独存储"
出站比对每次请求前现场重派生 + 全量字符串比对
失败策略抛异常,请求当场作废,发不出去
不变量原文(AGENTS.md L107):
*Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.*

出站站岗 · 四行金句


const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
  fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}

出处:packages/core/agent-loop/src/invariant.ts L39–42。

比对范围:消息数组 + 系统提示词 + 模型名 + 采样参数 + 工具清单,全部与日志里的请求头快照逐字段对齐。出处:invariant.ts L22–52;请求头重建 packages/core/session/src/request-header.ts L65–71(七行纯函数)。

第二节 · 双队列 × 三语义

APItargetwakeup进哪条队何时被接走
followupnext-turn唤醒等下一班当前 Turn 跑完,一班只接一条
steernext-step唤醒插当前这班当前 Step 跑完,下一步开头
injectnext-step不唤醒插当前这班下一个自然 Step,不催开工

三个 API 全是 send() 的参数预设,各自只有三行。出处:packages/core/agent-loop/src/agent.ts L113–132。


思路一 · 只留一份真相

问题

大多数聊天程序都有两份对话:内存里一份数组,磁盘上一份存档,各写各的。进程崩了从存档恢复,恢复出的历史比模型当时实际看到的少了一条工具结果 —— 模型接下来的回答全对不上号,还查不出原因,因为两份状态谁也证明不了谁。

只要真相有两份,它们迟早漂移。

提示1 · 双向箭头:两个方向都要成立

  • 顺方向:日志 推得出请求;
  • 逆方向:请求 必须能被日志解释。

光写日志做不到第二点 —— 写日志是被动的,谁都可以绕过它直接改请求。所以必须有出站站岗。

提示2 · 检查者和被检查者共享同一套规则

检查用的派生函数,和恢复、回放用的是同一套公开函数。

如果检查用一套私有实现,两套实现迟早也会漂移 —— 等于给自己造了第二个真相。

请求头重建只是一个七行的纯函数:扫一遍事件、取最后一个快照。


思路二 · 验不过就崩,不要带病运行

为什么告警不行

告警意味着违规请求已经发给了模型:某个插件绕过日志偷偷改了消息,模型看到的和日志记的从这一刻起是两回事。日志接着记,记的全是错账。三天后拿这份日志回放排查,怎么都复现不出线上的怪行为。

静默偏差比崩溃可怕 —— 它把排查成本悄悄转嫁给了未来。

机制

比对不过 → 抛异常 → 这次请求当场作废,根本发不出去。

设计笔记明确否决了温和方案,对「比较连续请求,发散时告警」这条备选的判词是:「因违规必须在接口层面不可表达而否决」。

出处:.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md「曾考虑的替代方案」一节。

提示3 · 两个配套小机关

机关作用出处
检查器插在事件监听队列队头防止别的监听器提前短路、把检查静默跳过invariant.ts L20–21、L54
请求对象与消息数组深度冻结堵住「先过检、再改内容」的后门invariant.ts L22–29

为什么长期成立:这就是 fail-fast。崩溃把损失锁在零 —— 日志里没有被污染的轮次,修好 bug 重跑即可。带病运行的成本是复利,跑得越久坏数据越多,最后连哪天开始坏的都查不出来。


思路三 · 一份日志买到全家桶

能力原理
恢复进程崩了,从日志重新派生一遍,接着聊
分叉从任意一条事件岔出去,开一条平行会话
回放把日志再派生一遍就是当年的请求,连 API Key 都不用
审计界面上看到的轨迹就是模型看到的内容,有运行时断言背书
压缩摘要同样以事件形式写进日志,压缩后的请求照样过出站比对

学名 event sourcing(事件溯源):银行和会计系统用了很多年 —— 不存余额,只存流水,余额永远从流水算出来。

横向对比 · 都写日志,谁在反向校验

方向持久层是否承担正确性职责
DSH日志 = 真相,出站反向比对是
Grok Build内存是主、落盘是从(单行道)否
Claude Code事后记录型(基于公开证据)未知

Grok Build 每来一条消息把内存条目拷一份丢进落盘通道,发送结果直接丢弃,落盘失败也不打断对话;压缩时允许把整份落盘历史一次性替换掉。出处:crates/codegen/xai-grok-shell/src/session/chat_persistence.rs L30–38。

差别不在有没有日志,而在方向 —— 前两家把持久化当恢复手段,DSH 把日志升格为需要运行时证明的第一性原理。

思路四 · 插话要分三种,粒度是产品能力

问题

只有一条消息队列时,用户说话只有两种命运:打断,或者排队。

Agent 正在按计划改十个文件,改到第三个你发现方向偏了。打断,前两个文件的活白干;排队,只能眼睁睁看它把十个文件全改错。你想做的只是补一句话 —— 这两个选项都要你拿当前进度去换。

机制:先拆两层,再编码两个参数

  • Turn = 一轮完整工作;Step = 一次模型请求 + 它触发的工具执行。一个 Turn 里通常有好几个 Step。
  • 拆开的原因很实际:消息需要一个比整轮更细的投递点 —— 模型下一次能看到新消息的机会,就是下一个 Step 的开头。
  • 两个参数:target(排哪条队)+ wakeup(要不要叫醒司机)。

三种语义的生活化例句

API例句
followup「这个改完之后,帮我再把测试补上。」
steer「等等,配置文件用 YAML 写,别用 JSON。」
inject「顺便说一句,用户刚把分支切到 main 了。」
为什么长期成立:这是中断的分类学,与实现语言无关。任何 agent 系统重写一遍,还是要回答同样两个问题。把分类做成 API,用户补一句话就有了第三种命运。

思路五 · 取消息要用领取制(claim)

问题

多处代码都能从同一条队列读消息时,两种事故迟早发生:

  1. 循环取一次、某个插件又取一次 → 同一条消息进两遍对话历史;
  2. 读了还没处理完进程崩了 → 重启后这条消息不知去向。

机制

  • 每个 Step 开始前循环调用一次 claim,原子地取走 next-step 队列的全部消息;
  • 碰上轮次边界,再多取一条 next-turn 消息 —— 注意是一条:连点三次 followup 会得到三个独立的 Turn;
  • 取走动作落盘为一条纯删除事件 → 消息只有两种状态:在队列里 / 归属某个 Turn,没有中间态;
  • 崩溃后重放日志,照着删除事件走,不会重复消费。

提示4 · 被 pre-step 插件拒绝的批次不放回队列

claim 先于裁决发生。拒绝时不开新 Step,轮次直接以 blocked 收场 —— 被拒批次不回队。

出处:packages/core/agent/src/inbox.ts L71–78;agent.ts L229、L266–269。

为什么长期成立:领取制是消息队列几十年的老共识 —— 数据库里叫 SELECT FOR UPDATE,SQS 里叫可见性超时,本质都是把读取和占有合成一个原子动作。

思路六 · 中断后的唤醒要降级

问题

按 Esc 中止当前活动,紧接着发一条 steer。steer 的语义是插进当前 Turn 的下一个 Step,但这个 Turn 正在死掉,它的下一个 Step 永远不会到来。

照原样入队只有两种坏结局:消息永远躺在队列里没人接(会话卡死);或强行插进一个正在收尾的回合(行为无法预测)。

提示5 · 降级判断发生在入队之前

send() 在入队前先看一眼现场:这条消息要求唤醒,而当前活动已被中止?

  • 把投递目标改写成 next-turn;
  • 唤醒请求先上闩记着;
  • 等被中止的活动善后完毕、状态收敛到空闲,再重放唤醒,开一班全新的车。

inject 不要求唤醒,不受降级影响 —— 照常排进 next-step,等新一班车的第一站被顺路接走。

出处:agent.ts L113–120(判断与目标改写)、L164–193(wakeDriver 上闩与重放);另见 L299(next-step 非空时 Turn 不关闭)、L324–329(队列空则收工,有存货则换 AbortController 继续)。

为什么长期成立:这是并发系统的通用命题 —— 事件到达时,它的目标正在死亡。答案也是通用的:别追一个正在退出的执行体,把事件重新排到下一个稳定边界。操作系统给正在退出的进程递信号、Actor 系统给正在停机的 Actor 发消息,套路都一样。

审查清单

  • 系统里有没有第二份对话状态?(有则必然漂移)
  • 出站请求是否与日志现场派生结果做了全量比对?
  • 检查用的派生函数是否与恢复/回放共用同一套?
  • 比对失败是崩溃还是告警?(告警 = 已经发出去了)
  • 检查器是否插在监听器队头?请求对象是否深度冻结?
  • 压缩摘要是否同样以事件形式写进日志?
  • 插话是否区分了 followup / steer / inject 三种语义?
  • 取消息是否用了领取制(读取+占有原子化)?
  • 被拒批次有没有误放回队列?
  • 中止后的唤醒输入是否降级改排 next-turn?

排查路径

  1. 回答对不上号 → 查是否有两份状态(内存 vs 落盘)发生漂移。
  2. 请求发不出去、当场崩 → 这是设计内行为,查哪个插件绕过了日志改消息。
  3. 消息进两遍 / 改动作重复执行 → 查 claim 是否非原子、崩溃重放是否重复消费。
  4. 消息永远没人接 → 查是否发给了正在死亡的 Turn(降级未生效)。
  5. 回放复现不出现象 → 说明日志已被污染,检查告警路径。

课堂练习

  1. 某插件监听请求出站事件,想在发出前往消息数组里塞一条系统提示。推演两种情况:它直接改那个被冻结的数组会发生什么?它先克隆整个请求、改克隆体再转发,又会在哪一步被拦下?(提示:冻结检查与逐字节比对各自守住什么)
  2. Agent 正在 Turn 3 的 Step 2 流式输出,插件调用 inject()。推演:这条消息最早在哪个时刻被领取?如果 Step 2 本来是本轮最后一步,它会被丢掉,还是把 Turn 续命一步?如果 Agent 已经空闲,它要等到什么时候才被消费?

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号或产品行为。
  2. 取舍说明:两节合计 8335 字,口播稿按 4600–5300 字容量取舍,保留最能带走的原理与踩坑点,未为凑字数硬扩。
  3. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  4. 源码时效:依据本地仓库 deepseek-harness-master,核对日期 2026-08-13;横向对比部分基于已公开材料。
  5. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。

Takeaway

DSH 的会话日志是对话的唯一真相,恢复、分叉、回放、审计共用同一份事件流。每次请求出站前从日志现场重建并逐字节比对,对不上当场抛异常,请求发不出去。崩溃优于告警 —— 因为请求发出去的那一刻,日志就再也解释不了模型的行为。

DSH 用 next-turn / next-step 两条持久队列把说话的时机编码成数据,三个 API 只是 send() 的参数预设。claim 是原子交接:消息要么在队列,要么归属某个 Turn,被拒不回队。中断后的唤醒输入一律改排 next-turn,因为死掉的 Turn 不再有下一个 Step。


*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*