DeepSeek Harness 底层拆解:为什么它把"聊天记录"做成了一条事件日志
从事件日志、Agent 循环与工具管线,理解可回放、可审计、可恢复的运行时设计。
博客本文目录
原稿写于 2026 年 8 月 14 日,基于
@deepseek-ai/dsh 0.1.0-rc.6的 README 与源码整理。以下保留当时的技术记录。
有一个反直觉的事实:在 DeepSeek Harness(dsh)里,不存在”聊天记录”这个一等公民。
你看到的那个聊天界面,是派生的。真正的数据本体,是一条 append-only 的事件日志。
这不是一个实现细节,这是整个框架的地基。理解了它为什么放弃”存消息”、选择”存事件”,你几乎就能理解它后面所有的设计决策——循环为什么可以换、工具为什么是一条管线、沙箱为什么这样切、子代理为什么能 fork、权限为什么要”逐次签字”。
这篇文章不讲功能清单,讲底层机制。我会沿着一条主线走:一个 agent 框架,怎么把”AI 替你干活”这件事,变成可回放、可审计、可恢复的数据。
一、地基:会话是日志,消息是投影
先说结论性的那句:dsh 的 Session 采用事件溯源(Event Sourcing)。append-only 的日志是唯一真相源;模型看到的消息历史,是从日志里增量投影出来的视图。
为什么这么设计?因为”聊天记录”这个抽象,撑不住 agent 的真实需求。
普通聊天工具里,历史就是一堆消息按顺序排好。但 agent 的会话里混着很多”不是消息”的东西:工具调用的开始、审批的问与答、turn 的边界、模型的原始 token 流、压缩发生的时刻、子代理从哪一行分叉出去。这些东西一旦塞进”消息”这个扁平的模型,信息就丢了。
所以 dsh 把会话拆成了事件。一次工具调用是 tool/call 事件,结果是 tool/result 事件,一轮对话被 turn/start 和 turn/end 包起来,每个 assistant/message 里记录着它由哪些 assistant/chunk 拼成——通过 sourceEventSeqs 引用源事件。而”模型看到的消息历史”,由 deriveMessages() 从这些事件里投影出来。
每个事件还带着三个结构化字段,这很能说明设计的严谨程度:sourceEventSeqs 记录它引用了哪些早先事件;surfaceOp 标记它是”新增”还是”替换”;ignorable 标记未知类型的事件是否可以安全跳过。最后一个尤其关键——它解决的是日志格式会演进这个现实问题:未来某个版本写入了一种新事件,老版本读到时,如果它标了 ignorable 就跳过,没标就拒绝重建,绝不静默误解。
这一步是理解后面一切的关键:日志是全部,消息是视图。
这个设计直接催生了几个能力,每一个都对应一个真实痛点。
第一,崩溃可恢复。 程序崩了、电脑断电了?按 id 恢复,turn 编号接着走,历史从日志原样重建。更妙的是崩溃修复的语义:如果日志里有一条 tool/call 却没有对应的 tool/result,恢复时会合成一个 TOOL_OUTCOME_UNKNOWN 结果,附上一段明确的风险提示——“结果未知,是否重试取决于操作是否幂等;若可能有副作用,先验证外部状态或询问用户,不要盲目重试。“它不替你猜,它把”这次调用的结果是不确定的”这个事实诚实地交给模型。
第二,压缩不删历史。 上下文撑爆窗口时怎么办?dsh 的 compaction 做的是 replace 操作:追加一条新的 surface 条目,把旧的影子节点”遮住”,而不是删除原始日志。日志永远 append-only,被遮住的旧事件还在,只是不再进入未来的输入。这保证了审计的完整性——压缩是为了省 token,不是为了毁证据。
第三,分叉只在稳定边界。 fork() 让子代理带着父会话的前缀去干活,但它只允许在”关闭的 turn”这个稳定边界切分。你不能在一个 turn 开到一半的时候 fork,因为那样切出来的是一段未完成的执行状态。这个约束看起来麻烦,实际上是在保证:任何被 fork 出去的历史,都是可独立成立的一段完整叙事。
第四,请求可重建。 每个 request/header 事件记录下完整请求快照——系统提示、工具 schema、调用配置、会话前缀——并标注是 initial、resume 还是 change。这带来一个近乎苛刻的能力:事后你可以精确重建”当时模型到底收到了什么”。出了事故要复盘,这个能力是决定性的。
二、驱动:整个框架只有一个循环,其余全是插件
dsh 里有一个包叫 dsh-agent-loop,文档里有一句话值得单独摘出来:这是整个 harness 里唯一包含具体循环逻辑的包。
也就是说,这个框架里,“调用模型、执行工具、再调用模型”这个核心循环,代码只写了一次。其他一切——沙箱、审批、压缩、持久化、子代理、UI——都是挂在这个循环周围的插件,通过一套事件分类体系(event taxonomy)与它协作。
那么”驱动”一个 agent 是怎么回事?核心是一个统一的 send() 原语,它有三个预设别名,对应三种不同的输入方式:
followup():把内容追加到”下一轮”的 FIFO 队列,并唤醒 driver。这是最普通的”接着聊”。steer():把内容追加到”下一步”的 inbox,并唤醒。这是”插队式”的即时引导,不进队列,下一步就用。inject():把内容追加到”下一步”的 inbox,但不唤醒。这是”静默注入”——先把上下文塞进去,等 agent 因为别的原因被唤醒时再用。
这三个原语区分了”排队等”、“插队催”、“塞进去但别吵醒”三种语义。听起来简单,但对一个要支持多轮交互、又要支持外部系统注入上下文的框架来说,这是把”什么时候该推进”这个问题做对了。
在 turn 边界,driver 会原子地”认领”(claim)待处理的 next-step 输入加上一个排队提示。认领是纯删除的 splice——每一批输入先发出事件再改投影,每个事件都可追溯。认领之后,agent/pre-step 决定这一步进入什么消息。
还有一处体现”循环尽量薄”的设计:模型调用成功后,恰好锚定一条 assistant/message 完成事件——包括没有内容的调用、以及 max-tokens 截断的调用。这条锚记录”组装了什么内容、由哪些 chunk 拼成、用了多少 token”。为什么要在乎?因为消息历史的权威来源必须落在日志里,而不是飘在某个执行栈上。栈会丢,日志不会。
而并行工具调用,也在这个循环里被调度:并发安全的调用进入有界滚动池(默认并发 10),exclusive 调用是排序屏障——它前面的跑完、它独占运行、它后面的排队。模型可以标记哪些工具并发安全、哪些必须独占。这个并发语义不是口号,是写进了调度器。
三、执行:工具不是函数,是一条管线
在 dsh 里,“调用一个工具”不是模型喊一嗓子就执行。每次调用都要穿过一条执行管线:
pre-execute(allow/deny 门禁)→ 单调 guards → execute(超时/重试/指标包装)→ post-execute(检查/替换结果)→ finalizeContent → result 通知。
每一步都是一个扩展点,都可以被插件拦截。但有两个细节真正体现了设计功力。
第一,门禁与 guard 是两回事。 pre-execute 是”可扩展的 allow/deny”,可以拒绝也可以放行;而 tools.guard() 注册的是单调的守卫——它只能拒绝,返回的拒绝不能被后面的监听器翻案。这个区分解决了一个很实际的问题:如果所有人都能在流水线上”改判”,那么任何安全策略都可能被下一个插件撤销。dsh 把”可以商量”和”没得商量”分开,安全策略走后者。
第二,工具的作用域是分层的。 工具注册分两层:全局层,和单个 agent 的 scope。一个普通插件注册的工具是全局的;而通过 agent.ctx 注册的工具,只对这个 agent 可见,还能 shadow 掉同名的全局工具。restrict() 则能做 allow/deny 掩码——对某个 agent 藏掉一批全局工具。
这意味着什么?意味着同一个进程里,可以同时跑着一个用 native 工具调用的 agent,和一个用 Code Mode 的 agent,各看各的工具目录,互不干扰。工具目录不是写死的,是按 agent 现场组合的。这也是为什么 dsh 敢说”呈现方式与执行方式解耦”。
第三,Code Mode 的权衡。 dsh 支持让模型写代码来调工具:框架生成一套类型安全的 SDK(TypeScript 或 Python),模型通过保留的 run_code 通道执行多步操作。这里有个极其诚实的权衡藏在文档里:中间值是执行局部、不落日志的。
意思是:run_code 程序里每个工具调用的返回值,只有外层程序的 return 和 print 会回到模型上下文;中间的绑定值不持久化,session 回放时无法重建。这是刻意为之——落日志的中间值可能被 spill 策略裁剪,且程序可能写任意大。但代价是”可回放性”在这里打折了。dsh 的应对是:每个子调用仍然发出 tool/code-dispatch 事件配对(带 parent token 关联),保住了”这次执行调了哪些工具”的审计线索,只是不保留”每次返回了什么值”。
这种”我明确知道在哪一步做了权衡、并写进文档”的态度,是这套框架最让人信服的地方。
四、边界:沙箱不是”拒绝权限”,是”包装命令”
大多数工具的沙箱逻辑是”检查一下这个命令能不能跑”。dsh 的思路更底层:ctx.sandbox.confine(argv, policy) 返回一组包装后的 argv,让你拿去执行——“用这个替代你自己原来的命令”。
包装后的进程连同它 spawn 的一切子进程,都跑在受限环境里。这就是”进程沙箱”和”文件过滤”的本质区别:前者约束的是执行体,后者只拦文件访问。
三个文件效应档位:read-only、workspace-write、danger-full-access。策略跟着每次调用走,不是全局开关——同一个会话里,这条命令只读,那条命令能写工作区。
最硬的一条是失败关闭。平台没有可用的沙箱后端?直接抛 SANDBOX_UNAVAILABLE,拒绝执行,绝不降级裸跑。Linux 用 bubblewrap/Landlock,macOS 用 sandbox-exec,Windows 用 ACL 受限令牌——后端不在,就宁可不动。
而文档同样诚实地写明了边界:这个沙箱只管文件效应。网络、进程、syscall、设备、凭据,都不在这个缝的词汇表里。它做的是”同世界隔离”(共享宿主文件系统和内核),要容器、微 VM、远程执行,得换掉整个能力实现,而不是在这个缝上加 provider。这种”清楚说明自己不做什么”的诚实,在开源项目里相当稀缺。
升级权限也不是”把沙箱拨宽一档”。它是一次带理由的新请求,走人工审批通道,审批结果成对记录——approval/asked 和 approval/decided 都进审计日志。批了这一次,不批下一次。
五、扩展:三条缝,与一个被忽视的工程现实
单 agent 有天花板。dsh 用三条”缝”(seam)把它撑开,三条缝遵循同一个模式:接口定义与实现分离。
- 子代理缝:统一 API
ctx.subagents委托子任务,provider 决定子代理跑在本进程、另进程,还是未来的传输方式。调用方无感。 - 工作流缝:模型写一段编排脚本,扇出多个子代理分阶段并行。脚本只做协调,不碰具体文件。
- 目标缝:一个 agent 挂一个持久目标,跨多轮自动推进,能暂停、能恢复、能标记阻塞,每次变更进事件日志,用 revision 做 compare-and-set 防呆。
但真正让我觉得”这是认真做过工程”的,是工作流的失败纪律。WorkflowError 带一个 fatal 标志:脚本解析错误、参数违规、并发上限被突破,这些是 fatal,会逃逸出 parallel() 和 pipeline();而一个子代理正常返回了非 completed 的结果,不是故障——agent() 返回 null,让脚本自己处理。
这个区分很微妙但很对:**基础设施的错,和任务的正常失败,不是一回事。**前者应该炸出来,后者应该交给编排逻辑。混为一谈的工作流框架,要么把普通失败当事故,要么把真正的故障吞掉。
最后说一个贯穿始终、却最容易被忽略的工程现实:KV cache 友好性。
dsh 的每个模块文档里都有一个 “KV Cache effect” 小节。为什么?因为模型推理的前缀缓存是真实成本:如果每轮对话的历史字节保持稳定,推理就能复用前缀,省下大量 token 和时间。dsh 反复强调 append-only——只要系统提示、schema、历史保持字节一致,前缀就稳定。而 compaction 的 replace 操作会从第一个被遮住的消息起让缓存失效。这就是为什么压缩要”遮住”而不是”重排”——重排会让整个后续全部失效。
一个框架把”对 KV cache 的影响”当作每个功能的设计约束来记录,说明它想的不只是”能不能跑”,而是”跑起来贵不贵”。
收尾
回头看,dsh 的设计有一条清晰的主线:
会话做成事件日志,是为了可回放;循环做成唯一且薄,是为了可替换;工具做成管线加分层作用域,是为了可拦截;沙箱做成包装 argv 加失败关闭,是为了可约束;三条缝加 KV cache 记录,是为了可扩展且不失控的成本。
这些词——回放、替换、拦截、约束、成本——都不是模型的能力,是 harness 的职责。
模型的强,会继续涨。而 harness 决定了一件事:当 AI 在深夜替你干活,第二天早上,你能不能说清它干了什么、凭什么干、花了多少。
DeepSeek Harness 给了一个相当认真的参考答案。