深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 第 44 章

stdio 上,程序对程序的 JSON-RPC

JSON-RPC server 产品面:一个常驻 dsh 子进程,在 stdio 上跑换行分隔的 JSON-RPC 2.0,把 Agent 运行时暴露给宿主程序。它不是给人看的 CLI,而是给别的程序——尤其是下一章的 TS/Python SDK——调用的机器入口。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一段 Python 或 Node 程序 spawn 起一个 dsh 子进程。没有终端、没有 stderr 滚屏——它向子进程的 stdin 写下一行 JSON:{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}},然后盯着 stdout 等一行回来。子进程起来,跑过 loader,配好 provider 与模型,回一行 {"jsonrpc":"2.0","id":1,"result":{...}}。宿主再写一行 session/prompt,子进程边跑边往 stdout 推一串串没有 id 的 session.event 通知,最后回一行带 result 的帧。这就是 JSON-RPC server:第 43 章那张”问一句话就退出”的脸的常驻孪生兄弟——同一个 Agent 运行时、同一份 append-only 会话日志,只是出口从终端换成了 stdio 上的帧,驱动者从人换成了程序。

传输:换行分隔,不是 LSP 头帧

先纠正一个常见误述。这条线的传输不是 LSP 风格的 Content-Length: N\r\n\r\n 头帧,而是最简单的换行分隔 JSON(NDJSON)。packages/sdk/protocol/src/transport.ts 顶部注释写得明白:“Newline-delimited JSON-RPC 2.0 over byte streams. Frames with id and method are requests, id alone is a response, and method alone is a notification.” 一个 StringDecoder('utf8') 攒住 onData 的 chunk,扫到 \n 就切出一行、trim() 后 JSON.parse;畸形行直接忽略。写出端就一行:this.output.write(JSON.stringify(message) + '\n')。

帧的三态由此定义:

帧形态方向语义
有 id 有 method双向请求,对端必须回一个同 id 的帧
有 id 无 method服务器→宿主响应(result 或 error),解开宿主的 pending
有 method 无 id服务器→宿主通知,服务器主动推,宿主不回

错误码是 JSON-RPC 约定俗成的两个:未注册方法回 -32601 method not found: <method>,handler 抛错回 -32603 <message>。客户端用 req_<uuid> 作 id、把 pending 请求存进一个 Map,响应帧回来按 id 取出 resolve/reject——这就是为什么两端都只需要一根双向字节流,不需要任何额外的多路复用协议。

JSON-RPC over stdio:宿主进程 spawn dsh SDK server 子进程。stdin 走请求帧(jsonrpc/2.0 + id + method + params),stdout 走响应帧(id + result/error)与无 id 通知帧。生命周期:initialize(await loader、配 provider/model、置 initialized)→ session/prompt(getOrCreateSession 惰性建会话、推任务、边跑边推 session.event/status/subagent.*)→ shutdown(memoized 单次退出:flush→dispose→exit(0))。帧格式为每行一个 JSON 对象、换行分隔,非 LSP Content-Length 头。

本节源码依据(4 处)

三个 wire 方法:initialize、session/prompt、shutdown

协议类型表里的 HarnessSdkRequestMap 只列了三个方法——不是旧文档里那种 initialize / sessions/create / prompt / sendEvent / dispose / shutdown 的六方法矩阵。真相要干净得多:

wire 方法params返回做什么
initializeprovider/model/reasoningEffort/maxTokensInitializeResult就绪边界,配模型后置 initialized
session/promptsessionId + contentBlocksSessionPromptResult惰性建会话、推任务、等静止
shutdown无空对象memoized 收尾退出

handleRequest 的 switch 就这三个 case,default 直接抛 unknown DeepSeek Harness SDK runtime method。注意没有 sessions/create 这个方法——会话不是宿主显式开的,而是 session/prompt 进来时经 getOrCreateSession 惰性建出来的:查 sessions 表命中就复用,没命中查 sessionCreations 的 in-flight Promise 去重,都没有才 createSession。也没有 sendEvent——服务器主动往外推的事件走无 id 通知帧,不需要宿主发起。也没有 dispose——资源回收合并进了 shutdown。这个减法是有意的:宿主要做的事被压成”配一次模型、发若干条 prompt、说一次再见”,其余全是服务器自己的事。

本节源码依据(3 处)

initialize:先让 loader 跑完,再承认自己就绪

initialize 是这个运行时的”就绪边界”,它的每一步都在防”半初始化”。packages/sdk/server/src/index.ts 的 transport.onRequest 拦截器里,method 是 initialize 时先 await ctx.get('loader')?.await()——注释解释:这个插件可能比异步兄弟 Loader 项(比如某个 MCP client 的初始工具发现)先激活,所以在整棵树 settle 之前,不能对外宣称自己 ready。

进到 initialize 本体(server.ts),先校验 reasoningEffort:非字符串或空串抛 initialize reasoningEffort must be a non-empty string,合法则打成 ReasoningEffortId。然后解析 provider adapter:当前只认 deepseek-official,别的 provider 直接抛 no adapter registered for provider "<id>"。接着 await llm.resolveCallConfig({ provider, model, ...reasoningEffort }) 把模型配置真正落到 LLM 服务上,最后才把 this.initialized = true。

这个顺序的推理很关键:initialized 是一道闸,它必须在所有异步配置都落地之后才抬起。prompt 第一行就 if (!this.initialized) throw new Error('SDK server is not initialized')——宿主绝不能在模型还没配好时就推任务。而 createSession 又一次声明它不组合 preset:“this server’s compositions keep the model-facing rows in the host plane”,和 headless 一样,模型选择从全局层读。

prompt:跨 await 两次断言活会话

session/prompt 进来,先 getOrCreateSession(params.sessionId),再 await durablePromptContent(ctx, params.contentBlocks)——这一步把宿主给的内容块规范化:编码图片经附件库落盘后再取回引用(“SDK image prompt requires an attachment store”),纯文本块原样透传。然后两次 assertLiveAgent(rec, params.sessionId):第一次在 await 之前,第二次在 durablePromptContent 的 await 之后。

为什么要两次?因为两次断言之间隔着一个异步边界——附件库落盘是 await 的,这段时间里会话可能被 shutdown、可能被 dispose。注释把这道双断言写进了 assertLiveAgent 的契约:rec 背后的 Agent 若已不存在,立刻抛错,绝不让一个死掉的会话拿到任务。这和 headless 里”快照闸门 + 活日志闸门”是同一种防御哲学——跨 await 的世界不可信,关键操作前后各查一次。

服务器主动往外推的四个通知,正是在 initialize 时一次性挂好的:session/event 事件翻成 session.event、agent/status 翻成 session.status、session/created 翻成 subagent.started、subagent/end 翻成 subagent.finished。其中 subagent/end 只报本进程内的子代理(info.local),停止原因经 successStatus 判定——max-tokens 在 maxTokensAsSuccess 选项打开时映射成 ok,否则 error。

本节源码依据(5 处)

四个通知帧:服务器单方面开口

宿主发的请求是一问一答,服务器却经常需要不等问就说。这些都是无 id 的通知帧,宿主的 onNotification 回调拿到 (method, params) 直接分发:

通知 method何时推payload 关键字段
session.event会话日志追加一条sessionId + 规范化后的事件体
session.statusAgent 状态翻转sessionId + status
subagent.started本进程起一个子代理子会话标识与父会话关联
subagent.finished本进程子代理结束停止原因(max-tokens 可映射 ok)

这里有个边界要写清楚:subagent.finished 只覆盖本进程内由这个 server 直接派生的子代理。subagent/end 处理器里有一道 info.local 过滤——远端的、跨进程的子代理结束,不会被这台 server 当成自己的通知推给宿主。这是责任边界:这台 server 只对它亲手 create 的那棵 Agent 树负责。宿主想知道更广的子代理拓扑,那是另一条(网关)线的事。

为什么通知走”无 id 的 method 帧”而不是请求?因为通知是单向的:服务器不需要宿主回复,宿主回了服务器也不监听。JSON-RPC 2.0 用”无 id”精确表达了这种单向性——它不需要配对、不需要 pending、不需要超时。宿主漏处理一条通知,不会留下一个悬挂的 Promise。

本节源码依据(3 处)

为什么是 NDJSON,而不是 LSP 头帧

这个选择值得停下来想一秒。LSP(Language Server Protocol)用 Content-Length: N\r\n\r\n 二进制式头帧,好处是一个帧可以跨多行、可以嵌任意换行;代价是要实现状态机解析器、要处理半帧粘包。这条线选 NDJSON,是因为它的帧本身就不能含裸换行——JSON.stringify 出来的字符串是单行的,文本里的换行会被转义成 \n。于是”按行切”天然正确:每行就是一个完整 JSON,粘包由”攒到换行”解决,半帧由”留着等下一个 chunk”解决。

实现代价因此极低:一个 StringDecoder + 一个 indexOf('\n'),没有长度前缀、没有状态机。而它换来的是可调试性——宿主或服务器出问题时,你可以直接 cat 那根 stdio,每行就是一条可读记录。对一个”给别的程序当后端”的常驻进程,出问题时能肉眼读协议,比省下的解析复杂度值钱得多。

本节源码依据(2 处)

shutdown:只退出一次的 memoized 收尾

shutdown 是这条线唯一的”再见”,而它最讲究的是幂等。shutdownTask ??= this.performShutdown()——共享一个 Promise,竞态的第二次 shutdown 拿到的是同一个进行中的 Promise,不会触发第二次 dispose。performShutdown 的顺序是:先立 shuttingDown 旗(此后 getOrCreateSession 直接抛 “SDK server is shutting down”),allSettled 所有在途的 session creation,清掉会话表,跑完 disposers,再 allSettled dispose 所有 Agent 与 llmFiber,最后把聚合错误包成 AggregateError。

而真正”进程消失”那一步在 index.ts:shutdown 处理结果写完之后,setImmediate(() => disposeAndExit())。disposeAndExit 也是 memoized(exitTask ??= ...),顺序是 transport.flush()(等最后一行帧写出去)→ rootFiber.dispose() → exit(0)。注释点明为什么用 setImmediate:shutdown 的响应帧要先落线,再开始拆家——否则客户端会在退出前收不到那个空 result。

整套设计回答了一个常驻进程最难的问题:宿主说”停”的时候,怎么保证最后一帧发出去、所有 Agent 被 dispose、而又不会因为竞态退出两次。答案是两处 memoized(shutdownTask / exitTask)加一条严格顺序(flush → dispose → exit)。

本节源码依据(3 处)

小结:机器的嘴,比人的嘴少

headless 对人说一句话就走;JSON-RPC server 对程序说一整轮。它把协议压到最薄:NDJSON 帧、三个方法、四个通知、一次幂等退出。薄的好处是任何语言都能照着这份类型表写个客户端——下一章就有两个现成的:TS SDK 和 Python SDK。它们不需要懂 Agent 内部,只需要会写一行 JSON、等一行 JSON。

下一章:第 45 章讲坐在这条 stdio 线两端的两个 SDK——TS 侧的 HarnessClient 与 Python 侧的 HarnessClient,同一协议的两副面孔。