一条鲸鱼的诞生:我的 DeepSeek Harness 工程手记
一、起点:一个我自己想用的东西
事情开始得很简单:我想在微信上用 DSH。
那时候 DSH 还叫 deepseek-harness,一个还在快速迭代的 monorepo。官方提供了 TUI(终端界面)、Web GUI 和一个 headless runner,三者都很好——但对于一个重度微信用户来说,它们都不够。
我想要的场景很朴素:在手机上发一条微信消息,DSH 在后台跑起来干活,把结果推回微信会话里。这样我不用开电脑、不用 SSH、不用记命令,随时随地都能用。
看上去应该不难。DSH 有 SDK,有 JSON-RPC 协议,有会话持久化。微信那端有 weclaw 桥接器。我需要做的只是把两端接起来——一个薄薄的 HTTP 服务,把微信的请求翻译成 DSH 的调用,把 DSH 的响应翻译回微信能看懂的格式。
于是有了 dsh-openai-server.mjs。
第一版我写了大概两百行。DeepSeekHarness 封装了子进程管理,runTurn 跑一轮对话,messagesToPrompt 把 OpenAI 格式的消息拍平成单提示。鉴权用 Bearer token,首启自生成并落盘。会话映射存到 dsh-sessions.json,重启不丢。很薄,很直接,两周后用得很顺手。
但我很快遇到了三个问题。
第一个问题:模型切换不跨渠道。
在终端里用 /model kimi-k3 切了模型,微信那边还是旧的。反过来也一样。我切模型是冲着“下次用这个”去的,但下次启动时模型又回到了环境变量指定的那个。
第二个问题:断连后的状态恢复。
微信会话是有状态的——同一个 X-Chat-Id 应该对应同一个 DSH 会话。我的桥通过 dsh-sessions.json 维护了这个映射,但重启后如果映射丢了,用户就得从头开始聊。状态外置是唯一出路。
第三个问题:官方的 REPL 很好,但我想多要一些东西。
官方 TUI 是 Ink 写的,功能完整,但它的工具卡渲染、终端键绑定、宠物系统、跨会话搜索——这些细碎的交互习惯,我总觉得能做得更贴合自己的手感。
于是我决定自己写一个终端前端。不是 fork 官方的,是从零开始写一个新的——dsh-repl。原因不是官方的不好,而是我想用自己的方式理解这个系统。写一个前端,是理解一个系统最快的方式。
现在回头看,这三个问题其实是同一条线索的三个节点:我想让 DSH 真正成为“我的”工具——能在我用的所有渠道里一致地工作,能记住我的习惯,能在我需要的时候以我想要的方式出现。
二、dsh-repl:做一个自己愿意用的终端界面
写一个 TUI 是件麻烦事。
终端不像浏览器,没有 DOM、没有 CSS、没有 Flexbox。每一个字符的定位、颜色、滚动、输入响应,都得自己算。好在我不是从零开始——@earendil-works/pi-tui 提供了 Container、ScrollView、Editor、Markdown、SelectList 这些组件。我只需要把它们组合起来。
但组合本身也是设计。
2.1 第一个决定:纯逻辑与终端胶水分离
这是整个 REPL 最重要的一步。
我坐下来想:这个 TUI 里,什么东西是“值得被测试”的?
答案不是按键处理、不是渲染循环、不是滚动行为——这些是终端胶水,它们依赖 PTY、依赖终端能力、依赖屏幕宽度,很难测,而且不值得测。真正值得测的是“会话事件如何映射到 UI 效果”——当 assistant/chunk 到达时,助手文本怎么追加;当 tool/call 到达时,工具卡怎么开;当 turn/end 到达时,状态栏怎么更新。
于是我把这个映射抽成了纯函数:
reduceSessionEvent(state, event, stats) → ReplEffect[]
输入是事件流,输出是效果列表,没有副作用、没有 I/O、没有终端依赖。测试可以构造事件序列,断言效果列表是否符合预期——就像测试一个 Redux reducer。
而 tui-repl.ts 只做一件事:把终端输入转换成事件,把效果列表渲染到屏幕上。它很薄,薄到可以被覆盖率豁免。
这个分离的收益在我后来写书时变得格外清晰——当我在第十三章写“测试与工程纪律”时,这个例子可以直接拿来用:把“值得断言的行为”与“不值得断言的胶水”分开,是 100% 覆盖率门禁不逼疯开发者的前提。这不是我从书里学到的,是我写代码时悟到的——然后在书里把它变成了方法论。
2.2 第二个决定:atfile 自包含
官方的 pi-tui 编辑器自带了一个 CombinedAutocompleteProvider,支持 @file 补全——但它依赖 fd 或 fdfind 二进制,缺失时静默返回空。这对我来说不够可靠:我不能假设用户的 PATH 上有这些工具。
于是我写了一个自包含的 AtFileProvider:用 readdirSync 递归扫描工作区,模糊匹配文件名和路径,支持 ~/ 展开和带空格的 @"path" 引用。递归深度和条目数都设了上限,防止在大仓库里卡死。
这个功能不在需求文档里,它来自一个直觉:“@file 补全应该永远工作,而不是在有 fd 的时候才工作”。这种直觉后来成为我写源码解读时反复追问的问题——“这个功能为什么这样实现,而不是那样?”
2.3 第三个决定:小鲸娘
这是最“非理性”的一个决定。
一个 TUI 不需要宠物。它不是功能需求、不是性能需求、不是安全需求——它甚至不是用户需求。但我还是写了。
pet.ts 有 419 行,比很多功能模块都长。状态机(idle/working/happy/sad/sleeping)、升级曲线、经验积累、持久化 JSON、晚宴/整点/节日彩蛋、记忆连续性(“你隔了一周回来,她记得上次见你是什么时候”)、工作时的“游泳鲸鱼”动画——它是一套完整的陪伴系统。
为什么?因为工具是可以被替换的,但陪伴是会被记住的。
当时我并不知道这个想法会出现在书里。但后来我写第八章“感官扩展”时,自然而然地写到了小鲸娘的“人格化设计”——不是功能,是产品隐喻。这个观点不是从源码里读出来的,是从我自己的代码里长出来的。写书的时候我意识到:一个工程师对一个系统的理解,不是来自读了多少行代码,而是来自他亲手在系统里留下了什么。
2.4 第四个决定:键盘协议的完整覆盖
终端输入比看上去复杂得多。
Kitty 键盘协议发了带修饰符的 CSI-u 序列——Ctrl+C 是 ESC[99;9u,而释放事件是 ESC[99;9:3u。如果区分不了 press 和 release,Ctrl+C 按一次就可能触发两次:第一次清空输入,第二次直接退出。那用户会疯掉。
iTerm2、Terminal.app、kitty、WezTerm——每个终端对同一个按键的编码方式都不一样。我花了很多时间用 DSH_REPL_KEYDEBUG=1 把每个按键的原始序列打到 /tmp/dsh-repl-keys.log,然后逐一适配。
还有粘贴合并。不发 bracketed paste 的终端会把多行文本拆成逐行的 \r 事件,每个 \r 都会触发一次 onSubmit,导致一个粘贴被拆成多个消息发送。我用 150ms 的合并窗口把连续提交攒成一条——这让我想起了当年用 56K 猫上网时“不要发太快,会丢包”的肌肉记忆。
这些细节在代码里看起来像是“噪音”,但它们是用户体验的基石。一个在手机上粘贴长文本的用户,不会关心 bracketed paste 是什么——他只关心“我贴进去的文字是不是完整地送到了模型那里”。
这个认知后来成了第十四章“实战全记录”的核心论点:好的工程代码,每一处防御性判断都在回答一个真实用户的真实痛点。我在书里写 dsh-openai-server.mjs 的 handleRunError 那段带日期的注释时,说的就是同一种东西——“2026-09-05 已踩”不是一段无关信息,是疼痛记忆。
三、读源码:从“怎么用”到“为什么”
写 dsh-repl 的时候,我一直在读 DSH 的源码。
不是因为我想写书——那时候还没有写书的计划。只是因为不读源码,很多问题我回答不了。
比如:会话事件的完整列表是什么?known-event-types.ts 里生成了 48 种类型,但文档里没有完整清单。我必须读源码才能知道 todo/write 和 goal/change 是会话事件,然后我才能在 reducer 里处理它们。
比如:JSONL 日志的格式是什么?session-persistence-jsonl 里用了 Zstandard 级联帧,每个帧独立可解。我必须读这个包的源码,才能在 history.ts 里复用 scanZstdFrames 解析会话历史。
比如:模型路由表是怎么解析的?interactive.cordis.yml 里用了 !!js 标签,普通 YAML 解析器读不了。我必须看 loadModelsFromConfig 怎么用自定义 schema 跳过标签,才能把同一份逻辑搬到 core.ts 里。
这些阅读不是为了写书,是为了让我的代码能与官方的代码互操作。
但读着读着,我开始注意到一些更深层的东西。
3.1 震撼一:Cordis 内核的设计密度
vendor/cordis/src/fiber.ts 有 754 行——不多,但每一行都在回答一个竞态条件。
效果(effect)的可逆性、epoch 驱动的依赖刷新、状态机的六态迁移、效果体执行期间的卸载保护——这些机制不是“写了就放着”的,它们是被真实竞态咬过之后补上的。vendor/README.md 里有 18 条本地修改日志,第 6 条关于 fiber.ts 的生命周期加固,正文写道:“效果的所有者登记先于效果体执行……同步失败时移除登记并回滚已收集的清理……”
这意味着什么?意味着热重载在真实负载上炸过,然后被人修好了。我看到的不是完美的设计,而是被现实打磨过的设计。这种“看见伤疤”的阅读体验,比任何架构图都更有教育意义。
3.2 震撼二:会话系统的诚实
session-persistence-jsonl 里有一个 ownership.ts,解决的是“两个进程同时写同一个会话”的问题。
锁的实现用了 O_EXCL 原子创建,锁的载荷是 pid、hostname、startedAt。但最打动我的是 fencing:每次追加都重新验证持有的声明——如果你的锁在你两次写之间被别人接管了,你的下一次写入会被响亮地拒绝。
“响亮地拒绝”是这里的关键词。它不静默降级、不悄悄重试、不假装没事——它说“你不能再写了,因为你已经失去了所有权”。这种诚实,比任何“优雅恢复”都更可靠。
3.3 震撼三:LLM 回放的逆向思维
dsh-llm-replay 是一个回放插件:从录制的事件重建模型流,让测试在零 API key 的情况下验证完整行为。
“录制一次,回放多次”——这个思路反过来想,是“代理的行为中真正重要的是事件序列,而不是 HTTP 响应”。回放不是模拟 HTTP,是模拟“模型会输出什么”。这让我重新理解了一个 AI 代理系统该测什么。
这些理解后来都进了书里。我写第三章“Cordis 内核”时,花了很大篇幅讲 fiber.ts 的六态状态机和效果可逆性——因为我知道读者需要看见“伤疤”,而不仅仅是“完美”。我写第五章“会话系统”时,把 ownership.ts 的 fencing 机制拆成了单独一节——因为“响亮地拒绝”是一种值得单独教授的设计态度。
四、写书:把理解和实践凝练成文字
写书的想法来得比写代码晚。
大概是在 dsh-repl 做到中后期的时候,我发现我对 DSH 的理解已经超出了“一个用户”的范畴。我读了源码的很多关键模块,写了一个完整的前端,做了一个桥接器,踩过 seq gap 的坑,修过运行时重建的竞态。如果把这些东西写下来,也许对别人有用。
但我不想写一本“官方文档的复述”。市面上已经有 README 和 AGENTS.md 了。我想写的是一个工程师在深入一个系统之后,愿意告诉另一个工程师的那些东西。
4.1 风格的确立
第一件事是定风格。我设了三条纪律:
第一,所有分析必须引用真实文件路径和函数名。“我觉得是这样”不够,必须说“在 packages/session/src/write-behind.ts 第 120 行附近,SessionWriteBehind 类做了……”这是为了可信——读者可以拿着书对照仓库,逐行验证。
第二,不确定的标注“待验证”。我不能假装知道我不知道的事情。如果某个机制我还没完全理解,就写“待验证”。这比编造好。
第三,中文全角标点是硬约束。正文里所有标点都是全角——这不仅是为了美观,是为了在写作时保持一种“纪律感”。当我在打全角标点时,我在提醒自己:这是在写书,不是在写笔记。
4.2 每一章的结构
每一章都按同一个结构写:开篇问题 → 源码定位 → 机制解剖 → 设计动机 → 实操要点 → 小结。
这个结构来自一个朴素的认知:读者打开一本书,不是为了听我说“这个系统很厉害”,而是想知道“这个系统是怎么做到的,以及我能不能做到”。
比如第四章“core:产品 API 脊柱”,我开篇问的是“Cordis 是通用插件框架,它不知道什么是智能体——让 Cordis 变成智能体框架的,是 packages/core 这个包组”。然后我从 scope 讲到 session,从 agent 讲到 agent-loop,从 tools 讲到 system-prompt。每一站都标了行号区间和函数名,每一站都以“你可以打开这个文件验证”收尾。
到了第十四章“实战全记录”,我直接把 dsh-openai-server.mjs 的 405 行从头拆到尾。每一步验证都给出了具体的命令、日志片段和预期结果。因为我知道读者最需要的不是“这个功能存在”,而是“如果你也要做同样的事,这十步可以照着走”。
4.3 最难写的几章
第三章“Cordis 内核”写得最久。Cordis 是地基,地基写不清楚,后面全塌。fiber.ts 的状态机、epoch 驱动的依赖刷新、效果的可逆性——这些概念我反复读了好多遍源码才敢下笔。初稿写完后,我对照 vendor/README.md 的 18 条修改日志又改了一遍,补充了“本地加固”那一节——因为我不希望读者觉得 Cordis 天生就是这么稳健,我希望他们看到稳健是被修出来的。
第五章“会话系统” 花了很大力气讲 ownership.ts 的 fencing 和 write-behind.ts 的批处理。这两个文件是整个系统“数据可信”的基石,但阅读门槛很高。我用了很多“如果…会怎样”的问句来引导读者——如果两个进程同时追加同一本日志会怎样?如果进程在写了一半时崩溃会怎样?如果旧进程以为自己还拥有锁但实际上已被接管会怎样?这些问题不是修辞,是我在写 dsh-repl 时真实碰到过的问题。
第十四章“实战全记录”是最后一章,也是唯一一章不直接来自源码阅读、而是来自我自己的项目经验的章节。这一章的挑战不是“解读”,而是“萃取”——从几个月的实战中挑出最有代表性的片段,把零散的经历整理成一条有逻辑的线索。我选了“last-model 四级回退”和“handleRunError 分级处置”两个案例,因为它们是“设计动机→实现→踩坑→修复”的完整闭环,而且每个环节都可以和前面的某一章对照——启动链路(第二章)、模型路由(第六章)、SDK(第十二章)、测试纪律(第十三章)。我希望读者在读完这一章后,不只是学到了 DSH 的某个功能,而是看到了如何把一本书的知识串联起来解决一个真实问题。
五、工程取舍:三件作品背后的同一套哲学
写完书、写完 REPL、写完桥接器之后,回头看这三件作品,我发现它们背后是同一套工程哲学在支配。
5.1 优先做“会被用到的”,而不是“会被看到的”
dsh-openai-server.mjs 只有 405 行。作为一个“把 DSH 暴露为 OpenAI 兼容 API”的服务,它小得让人意外。但这不是偷懒——是取舍。所有繁重的工作(进程管理、会话恢复、模型路由)都由 DSH SDK 承担,桥只做翻译。405 行里真正的“业务”只有四十行(拍平消息、拼响应),其余全是护栏(鉴权、检查、超时、关停、错误分级)。护栏是桥自己的责任,业务是运行时的责任——这个比例就是健康的比例。
这个取舍后来成了第十二章的论点之一:薄桥是协议质量的试纸。如果协议本身足够好,集成层就不需要做太多事。
5.2 设计先于实现
dsh-repl 的设计先于代码。我花了很多时间想清楚“什么值得测、什么不值得测”,然后才动手写。session-reducer.ts 的纯函数设计不是“写完之后重构出来的”,是“写之前就想好的”。这个顺序很重要——它是“设计”与“重构”的区别。
5.3 注释是给后来者的善意
dsh-openai-server.mjs 的 handleRunError 里有一段带日期的注释:
// 销毁一个还有工具在收尾的进程会制造"迟到 append"窗口,
// 过期 seq 落盘就是会话日志的 seq gap(2026-09-05 已踩)
这个注释不是解释代码做什么,而是记录代码为什么长成这样。没有那次事故,这段分级逻辑看起来就是过度设计;有了日期,每个读码人都能看见“简洁”与“正确”之间那条被事故画出的线。
同样的习惯出现在书里。每当我写到“这个设计有一个容易被忽略的代价”或“这条纪律曾被一次真实事故逼出来”时,我都在做同一件事:让后来的读者不必重新踩一遍我踩过的坑。
六、读后感:三件作品如何互相塑造
如果没有写 dsh-repl,我不会深入读 DSH 的源码——至少不会读到 ownership.ts 和 fiber.ts 那种深度。如果没有写书,我不会把那些散落的理解整理成一个体系,也不会意识到小鲸娘和五轨记忆之间存在“人格化设计”的共通点。如果没有写桥接器,我不会在第十四章里有一个真实的实战案例可讲。
三件作品互相塑造。写 REPL 让我有了理解源码的动机和上下文,读源码让我知道 REPL 该怎么设计才能与官方生态互操作,写书让我把这些经验固化成了可传递的知识。
现在回想起来,最让我意外的不是写了多少行代码或多少字,而是整个过程非常自然地形成了一个闭环:从“我想在微信上用 DSH”这个朴素需求出发,到写一个桥接器,到写一个 TUI,到深度阅读整个源码,到写一本十几万字的书——每一个阶段都是前一个阶段的自然延伸,没有哪一个步骤是“计划好的”。
这种“自然的闭环”让我想到在 DSH 源码里反复看到的那种设计——它不是先画架构图再写代码,而是先有一个真实的需求,然后一层层被逼出来的。Cordis 的 epoch 机制不是设计出来的,是被热重载的竞态逼出来的;ownership.ts 的 fencing 不是设计出来的,是被两个进程同时写日志的事故逼出来的;dsh-repl 的纯逻辑分离也不是设计出来的,是被“我想测它”这个需求逼出来的。
也许好的系统,无论是代码还是书,都是在回应真实需求的过程中长出来的,而不是在图纸上画出来的。
七、致谢与下一步
写这本书的时候,我时常想起在深夜里读 DSH 源码的那些时刻——在 fiber.ts 里追踪状态迁移,在 ownership.ts 里理解 fencing,在 dsh-openai-server.mjs 里调试 seq gap。那些时刻是孤独的,但同时也是充实的——因为我知道我在理解一个真正的好系统。
这本书是我对 DSH 的理解的一个快照。它当然不完整——任何对四十万行代码的解读都不可能是完整的。但它是诚实的:每一行分析都来自真实的阅读,每一个判断都标注了成色,每一个建议都附带了你今晚就能动手的第一步。
下一步是什么?我不知道。可能继续用 DSH,可能给它提 PR,可能在它之上做新的东西,也可能完全转向别的方向。但我知道一件事:读源码的能力是一种可以带走的能力。它不依附于任何特定的语言、框架或代码库。它只依附于一种习惯——当你想理解一个系统时,你不是去问别人,而是去读它自己的源码,然后在读的过程中做出你自己的判断。
我把这个习惯写进了书的第一章“读源码的方法建议”里,然后用了十四章的篇幅来示范它。如果读这本书的人能带走任何东西,我希望是这种习惯——而不是关于 DSH 的具体知识。
知识会过时,源码会漂移,但“知道该怎么读”永远不会过时。
——苑广山,二〇二六年九月