青雲的博客
深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 第 40 章

JSON-RPC Server:长期进程的暴露方式

JSON-RPC Server 把 Agent 能力暴露为长期运行的进程,管理多 session 并发,通过 stdio 或 TCP 提供服务。与 Headless 的一次性退出不同,Server 常驻等待请求、处理背压、支持优雅关闭。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

Headless 跑完一个任务进程就死了。但编辑器插件、IDE 集成、嵌入式场景需要的是另一个模型:启动一次 runtime,反复发任务,收流式事件,随时 cancel,不需要每次冷启动。

这就是 JSON-RPC Server 的职责。它不做一次性任务,它把 Agent 能力包装成一个长期运行的 JSON-RPC 服务端,通过 stdio(默认)或 TCP 暴露给外部客户端。客户端连上来可以发 initialize 握手、用 session/prompt 创建/复用 session 投递消息、订阅四类 notification、最后调 shutdown 优雅关闭。

Server 和 Headless 的差别不只是“外面套个 while 循环”。多 session 并发、空闲超时、背压缓冲、优雅关闭 drain,都是常驻服务必须处理、而 Headless 完全不用考虑的问题。

协议形状:三个 request,四个 notification

先看协议的 wire shape。@deepseek-ai/dsh-sdk-protocol 包定义了 TS 和 Python SDK 共享的类型契约。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '92,105p' "$repo/packages/sdk/protocol/src/types.ts"

客户端到服务端只有三个 request 方法:initialize(握手,传 cwd/provider/model/maxTokens)、session/prompt(给指定 session 投递一条用户消息)、shutdown(优雅关闭)。

服务端到客户端有四个 notification:session.event(每条 session 日志事件实时推送)、session.status(Agent idle/running 状态变化)、subagent.started(子 agent 创建)、subagent.finished(子 agent 结束)。

sequenceDiagram
    participant C as SDK Client
    participant T as JsonRpcTransport
    participant S as HarnessSdkJsonRpcServer
    participant A as Agent(s)

    C->>T: initialize {cwd, provider, model}
    T->>S: handleRequest("initialize")
    S->>S: mount LLM adapter if needed
    S-->>C: serverInfo {name, version}

    C->>T: session/prompt {sessionId, contentBlocks}
    T->>S: handleRequest("session/prompt")
    S->>S: getOrCreateSession(sessionId)
    S->>A: agents.create() / agent.followup()
    S-->>C: {messageId}

    loop AgentLoop running
        S->>T: notify("session.event", event)
        T-->>C: session.event notification
        S->>T: notify("session.status", idle/running)
        T-->>C: session.status notification
    end

    C->>T: shutdown
    T->>S: handleRequest("shutdown")
    S->>S: await pendingCreations
    S->>A: dispose all handles
    S->>S: unsubscribe all events
    S-->>C: {}

构造函数:订阅四个事件源,连接 transport

HarnessSdkJsonRpcServer 的构造函数接收一个 Cordis ctx(已经 boot 好的根容器)、一个 `JsonRpcTransportPeer(transport 抽象层,屏蔽 stdio/TCP 差异)、以及 options。构造时做了一件关键事:订阅 ctx 上的四个事件。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '65,104p' "$repo/packages/sdk/server/src/server.ts"

注意看它订阅的范围:ctx.on('session/event', ...)全局的 session 事件,不是只订阅 SDK 创建的 session。这意味着如果同一个 Cordis 根容器里还有别的 session(比如 Host 端通过 ApiProxy 创建的),它们的事件也会被 notification 推送到客户端。客户端靠 `sessionId 字段过滤自己关心的 session。

subagent 事件同理:ctx.on('subagent/end', ...) 监听所有子 agent 结束事件,但通过 info.local 过滤只报告 in-process 的子 agent(远程 ACP 子 agent 不报告)。通知 payload 里带 parentSessionId 和 childSessionId,客户端可以构建子 agent 树。

所有订阅的 unsubscribe 函数都存在 this.disposers 数组里,shutdown 时逆序调用取消订阅。

多 session 并发:getOrCreateSession 的去重

Server 管理多个 session 并发。this.sessions = new Map<string, SessionRecord>() 存活着的 Agent handle,this.sessionCreations = new Map<string, Promise<SessionRecord>>() 存正在创建中的 Promise。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '203,235p' "$repo/packages/sdk/server/src/server.ts"

getOrCreateSession 的逻辑:如果 sessions Map 里已有这个 sessionId,直接返回;如果 sessionCreations 里有同 id 的创建中 Promise,await 同一个 Promise(不会重复 create);都没有才调 createSession,把 Promise 放入 sessionCreations,创建完成后从 sessionCreations 删除、放入 sessions。

这个 pattern 解决了并发 prompt 同一个新 session 时的竞态:两个 session/prompt 请求同时到达同一个不存在的 sessionId,如果不加锁会 create 两个 Agent。sessionCreations Map 充当 per-sessionId 的 mutex,保证每个 sessionId 最多 create 一次。

每个 session 有独立的 Agent 实例(有自己的 AgentLoop、inbox、工具状态),但它们共享同一个 Cordis root ctx。这意味着 LLM adapter、workspace registry、model selection 等全局服务是共享的,但 agent scope 的配置(provider/model/cwd/maxTokens)在 create 时绑定。

优雅关闭:drain 而非 kill

shutdown 不是杀进程。performShutdown() 的 drain 流程值得细看:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '155,181p' "$repo/packages/sdk/server/src/server.ts"

顺序是:

  1. 设置 shuttingDown = true,阻止新的 session 创建(getOrCreateSession 开头检查这个 flag)
  2. await Promise.allSettled(pendingCreations)——等所有正在进行的 Agent 创建完成
  3. 清空 sessions Map,收集所有 SessionRecord
  4. 逆序 pop 并调用 disposers 取消所有事件订阅
  5. Promise.allSettled dispose 所有 Agent handle + dispose LLM adapter fiber
  6. 收集所有 rejection,如果有错误抛 AggregateError

这个顺序保证了:不会有新请求在关闭途中创建 session(flag 拦在入口)、不会有半创建的 Agent 被遗弃(等 pendingCreations 完)、不会有事件在 Agent 被 dispose 后继续推送(先取消订阅再 dispose Agent)。

容易踩的坑

坑一:以为 SDK Server 是 ApiProxy 的简化版。 完全不是一个东西。SDK Server 的 createSession 直接 ctx.agents.create(),没有 preset 组合、没有 workspace 归属、没有 cwd 校验、没有并发去重到 workspace 粒度。SDK Server 是给嵌入式场景用的纯运行时暴露,ApiProxy 是 Web 的 Host 控制面(第42章讲)。两者面向不同消费者。

坑二:以为 session.event 只推自己创建的 session。 构造函数里 ctx.on('session/event', ...) 是全局监听,同一个 ctx 里的所有 session 事件都会推送。客户端必须按 sessionId 过滤。如果你在一个已经有其他 session 的 ctx 上挂载 SDK Server,会收到那些 session 的事件流。

坑三:以为 shutdown 会等 Agent 跑完当前 turn。 performShutdown 直接 dispose 所有 Agent handle。dispose Agent 会中断正在进行的 LLM 调用和 tool 执行,不会等 turn 自然结束。如果需要优雅跑完当前任务再关,客户端应该先发 cancel 或等 session.status 变 idle 再发 shutdown。

坑四:以为背压由 Server 处理。 transport.notify() 是同步调用,如果 transport 的发送缓冲区满了(比如客户端消费慢),背压传播到 transport 层。Server 本身不做事件缓冲——如果需要有界缓冲或丢弃策略,要在 transport 层实现。SDK client 端有 queue 和 subscription 管理,但 server 端假设 transport 能跟上。

JSON-RPC Server 定义了协议契约,但谁来消费这个协议?TypeScript SDK 和 Python SDK——它们不是简单的 API wrapper,而是共享同一套协议类型定义的双生子。