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

两根语言,同一条线

TypeScript SDK 与 Python SDK:坐在 stdio 线两端的两个客户端,把第 44 章的 NDJSON 协议包成各自语言里顺手的样子。同一个运行时,两副惯用法。

本章以中文撰写

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

第 0 秒,一个 Python 脚本写下 with DeepSeekHarness() as h: result = h.run("run the tests")。同一时刻,一个 Node 服务写下 await using h = createProcessDeepSeekHarness(); const r = await h.run("run the tests")。两段完全不同的代码,底下是同一个子进程、同一根 stdio、同一套 NDJSON 帧——第 44 章那条线。SDK 这一层的全部工作,就是把那根裸线包成各自语言里”本来就该这么写”的样子。它薄得几乎透明:不实现任何 Agent 逻辑,只负责启动进程、配对请求、分发通知、幂等退出。

TS 侧:AsyncDisposable 与异步通知流

packages/sdk/client/src/api.ts 导出的 DeepSeekHarness 实现了 AsyncDisposable——这是 TS SDK 的脊梁。它的 start() 被 initialized ??= memoize:握手只做一次,重复调用复用同一个 Promise。握手内容就是往线上发 initialize(带 provider/model/reasoningEffort),服务器那边跑过 loader、配好模型才回。

HarnessSession.run(input) 是”问一句话”的高层:normalizeInput 把字符串或内容块规范化成 SdkPromptContentBlock[],client.prompt(this.id, contentBlocks) 发 session/prompt,然后订阅通知流,一路收集 session.event,直到整轮静止、turn 结束。finalResponse(events) 从事件序列里挑出最终那条助手文本——脚本拿到的不是一坨帧,而是一个干净的字符串。

通知是异步可迭代的。HarnessClient.subscribe() 返回一个 NotificationSubscription:它是 AsyncIterable<HarnessNotification>,for await (const n of sub) 就能逐条消费;还有 next()(等下一条匹配通知)和 tryNext()(drain 已排队但不等)。内部 NotificationSubscriptionImpl.push() 先看有没有 waiter 在等,有就直接 resolve 给它,没有就塞进队列——推/拉两种模式在同一个订阅里合一。subscribe(filter?) 还能传一个过滤函数,只把匹配的通知推给这个订阅——比如”只要 subagent.finished”,其余通知别的订阅去消费。多个订阅各收各的,fan-out 在客户端这一层就完成了。

订阅的 detach 语义也想清楚了:unsubscribe() 后,已排队的通知仍可 tryNext() drain 出来,但 pending 的 waiter 会被 reject——注释说”the first failure wins”,第一个失败决定这个订阅的命运,后续失败不覆盖。这避免了一个订阅在 transport 关闭后还挂着一堆永不 resolve 的 Promise。

错误是三个类型化的类:TransportClosedError(子进程退了/stdio 关了)、RequestTimeoutError(请求超时)、SdkProtocolError(比如 session/prompt 没回 accepted: true)。调用方 try/catch 就能区分”线断了”和”协议不对”。

同一根 stdio 线:TS SDK(DeepSeekHarness/HarnessSession,spawn + subscribe 异步流 + AsyncDisposable)与 Python SDK(DeepSeekHarness/Session,Popen + next_notification 轮询 + context manager)都包着同一个 NDJSON wire。线上是 initialize / session/prompt / shutdown 三方法与 session.event、session.status、subagent.* 通知;snake_case↔camelCase(max_tokens→maxTokens)的映射在客户端完成。对面是不关心语言的 dsh SDK server——同一个运行时,谁包线谁负责。

本节源码依据(4 处)

Python 侧:context manager 与 Pydantic 校验

Python SDK 的形状是它语言里最自然的样子。HarnessClient.__init__ 收一个 HarnessConfig,start() 用 subprocess.Popen 拉起 server,stdin/stdout/stderr 全是 PIPE;stderr 单独起一个线程、攒进一个 maxlen=400 的 deque——这是给排错留的:server 崩了,宿主能翻到最后 400 行 stderr 日志。

initialize() 的参数是 Python 风格的 reasoning_effort、max_tokens,但发出去之前映射成线上的 camelCase:payload["reasoningEffort"] = reasoning_effort、payload["maxTokens"] = max_tokens。这层映射是 SDK 存在的理由之一——Python 用户写 snake_case,线上协议是 camelCase,谁都不该在业务代码里手转。request() 用 Pydantic 的 response_model.model_validate(result) 把线上 JSON 校验成类型化对象,校验不过直接抛——线上多一个字段、少一个字段,客户端当场知道。

高层 DeepSeekHarness.run() / Session.run() 做的事和 TS 一样:发 session/prompt,然后 collect(notification) 收集通知,攒到 turn 结束,抽出 final。close() 是幂等收尾:先发一个 shutdown 请求(带 timeout),再关 stdin;stderr 线程 join(timeout=0.5) 收口。__enter__/__exit__ 让 with 块结束自动 close——Python 的资源管理惯用法,和 TS 的 AsyncDisposable 是同一个意图的两种写法。

关注点TypeScript SDKPython SDK
资源管理AsyncDisposable / await usingcontext manager / try-finally
子进程spawn(node:child_process)subprocess.Popen(stdio PIPE)
初始化握手initialized ??= memoizedself._initialized 布尔位
通知消费subscribe() 异步可迭代next_notification() 拉取
响应校验TS 类型 + 运行时校验函数Pydantic response_model
参数命名camelCase(与线一致)snake_case,映射层转 camelCase
本节源码依据(5 处)

退出梯子:EOF → SIGTERM → SIGKILL

SDK 最容易被写软的地方是”怎么弄死子进程”。TS 侧 disposeRuntimeProcess 不是一发 kill 了事,而是一架三段梯子:先优雅地让子进程退出(发 shutdown、关 stdin EOF),给一个宽限(disposeEofGraceMs,默认 6 秒);宽限到了还没退,发 SIGTERM;再不退,发 SIGKILL,并且只在进程真的发出 exit edge 之后才 resolve——注释强调”resolving only after the process has actually exited”。

为什么要这么讲究?因为 SDK 客户端跑在任何 harness 上下文之外——它不是 Host 的一个插件,它就是外部世界本身。如果它发个 kill 就不管了,一个卡住的 server 进程可能成为僵尸挂在系统里,占用会话目录、锁住日志。梯子的每一段都用 once('exit') 监听、超时即移除监听器,注释专门说明”梯子各层不堆积监听器”——这是多次 dispose 不泄漏的关键。

请求超时是同一套谨慎的另一面。HarnessClient.request 把单次调用包进一个带 AbortSignal 的竞速:超过 requestTimeoutMs 就 abandon.abort(new RequestTimeoutError(...))——注意这是主动 abandon,传输层会把 pending 条目丢掉,这样对着一个挂死方法反复发有界请求,不会越积越多。超时错误里还拼进了 stderr 尾行,方便排错。

本节源码依据(4 处)

错误谱系与响应模型

两端的错误都是一棵同构的继承树。Python 侧 HarnessError 是根,三个子类对应 TS 侧那三个类型化错误:TransportClosedError(子进程退了/stdout 关了)、SdkProtocolError(server 发了协议外的数据)、JsonRpcError(server 回了 JSON-RPC error 帧,带着 code/message/data)。调用方 except 这棵树,就能区分”线断了""协议错了""业务错了”——和 TS 侧 try/catch TransportClosedError / RequestTimeoutError / SdkProtocolError 是同一套分类学,只是换了语言的异常基类。

响应则是 Pydantic 模型。InitializeResponse 带一个 ServerInfo(name/version),Notification 是一个 @dataclass(slots=True) 的 (method, payload)——轻量到零校验开销,因为通知是单向的、不需要模型校验。IncomingRequest 同理。分层很清楚:请求/响应要校验(往返都得对),通知不校验(来了就消费)。这和 TS 侧”通知是裸异步流、响应走校验函数”完全同构。

本节源码依据(3 处)

为什么 SDK 敢这么薄

两副 SDK 都薄,是因为重的那头全在 server。客户端不做会话合并、不做投影、不做工具执行——它只做四件事:把参数序列化对、把 req_id 配对对、把通知分发对、把退出做干净。这意味着两边可以各自独立演进惯用法:TS 加个 subscribe 的过滤函数,Python 加个 Pydantic 模型,都不需要碰协议,因为协议在第 44 章那张类型表里是稳定的单一事实源。

还有一个推理点:两边都把 initialize 做成 memoized/一次性。为什么?因为 initialize 在 server 那边是”等 loader、配模型”的重活,重复握手既浪费又可能引入状态不一致。客户端记住”我已经配过了”,第二次 start() 直接返回——和 server 那边 initialized=true 的闸门正好对上:server 拒掉没握手的 prompt,客户端保证只握手一次。两端的幂等是互相咬合的。

本节源码依据(2 处)

一次完整调用,在两副 SDK 里长什么样

把上面所有零件拼起来,一个用户在两端各自写出的代码,结构惊人地一致。Python:

from deepseek_harness import DeepSeekHarness

with DeepSeekHarness() as harness:
    result = harness.run("run the tests")
    print(result.final_text)

TypeScript:

import { createProcessDeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'

await using harness = createProcessDeepSeekHarness()
const result = await harness.run('run the tests')
console.log(result.finalText)

两边底下发生的事一模一样:spawn 子进程 → initialize 握手(memoized)→ session/prompt 推任务 → 边收 session.event 通知边等整轮静止 → 抽出 final → with/await using 结束触发 dispose/close → shutdown → EOF→SIGTERM→SIGKILL 梯子收口。差异只在语法:Python 的 with 对应 TS 的 await using,snake_case 对应 camelCase,next_notification 对应 for await。

这就是 SDK 这层的终极价值:它把第 44 章那根”裸 NDJSON 线”藏起来,让一个 Python 数据科学家和一个 TS 平台工程师,用各自母语的惯用法,触达同一个 Agent 运行时。协议是稳定的骨架,惯用法是长在骨架上的肉——肉可以各自生长,骨架不动。

本节源码依据(2 处)

一次 run 的内部:从 prompt 帧到 final 文本

harness.run("run the tests") 这一行底下,不是”发一个请求、等一个响应”那么简单——它藏着一个收集循环。客户端把 session/prompt 帧发出去之后,并不只盯着那个最终响应帧;它一边收无 id 的 session.event 通知、一边把它们攒进一个事件序列,直到轮到”整轮静止”那个停止条件,才把最后一条助手文本抽出来当答案。这个循环两端写法不同,但形状一致:

阶段TypeScriptPython
发请求client.prompt(sessionId, contentBlocks)client.request("session/prompt", {...})
收流for await (n of subscribe()) 逐条收集while True: next_notification() 拉取收集
停止条件收到 turn-end 状态帧即停collect 循环遇到 turn-end 标记即停
取答案finalResponse(events) 抽末尾助手文本RunResult.final_text 直接可读
失败路径throw RequestTimeoutError / SdkProtocolErrorraise HarnessError 子类(协议/线断/业务)

为什么是”收集通知直到 turn-end”,而不是”sleep 一个超时”?因为第 43 章那个 whenIdle 是运行时侧的真实静止边界——工具调用全沉降、子代理全退出、回调全跑完,服务器才推那个结束状态。客户端等的是同一个边界,不是一个猜出来的时长。这也解释了超时为什么做成主动 abandon:网络卡住时,客户端不会永远挂在收集循环里,到 requestTimeoutMs 就 abandon.abort(...) 并把 stderr 尾行拼进错误——超时是兜底,turn-end 才是正常出口。

而 finalResponse(events) / RunResult.final_text 做的事,正是把攒下来的一串帧折成一个干净字符串:中间的 reasoning、tool_call、tool_result 都只是过程,眼睛只要最后那段正文。客户端不在这里做任何域判断——它信任服务器已经把”哪条是最终答案”编进了事件序列,自己只做抽取。

本节源码依据(3 处)

小结:协议是骨架,惯用法是肉

第 43 章的 headless 是给人的脸,第 44 章的 server 是机器的嘴,本章的两个 SDK 是长在这根嘴上的两副舌头。它们说同一种话(NDJSON),但各自用母语的腔调说——TS 的 await using、Python 的 with,背后都是同一个”启动一次、问若干轮、说一次再见”。

下一张产品面离开子进程,回到浏览器里:第 46 章讲 Host 与 Web 之间那条 HTTP/WebSocket 网关——当运行时要面对的是浏览器,而不是一个子进程时,协议长成什么样。