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

ApiProxy 与 SDK:Web 的控制面和客户端协议

ApiProxy 是 Web UI 的 HTTP/SSE 网关控制面,管理 session 生命周期、事件投影、设置和审批流;SDK 是外部客户端库,驱动同一运行时的 JSON-RPC stdio 协议。两者面向不同消费者,共享底层 Agent/Session 基础设施,但协议层、传输层、生命周期管理完全不同。本章对比辨析两者在架构定位、协议设计、session 管理路径上的关键差异。

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

你打开浏览器用 DeepSeek Harness 的时候,每次点击”新建会话”、发送消息、切换模型,背后走的是 ApiProxy——一个 40+ 方法的 HTTP 控制面。当你在 TypeScript 或 Python 代码里 new DeepSeekHarness() 驱动 Agent 时,走的是完全不同的路——一个只有 3 个方法的 stdio JSON-RPC 子进程协议。

两者都在“用 Agent”,但不能把它们理解成同一套协议的两层封装。SDK 不是在调 ApiProxy 的端点;ApiProxy 也不是 SDK 外面包了一层 HTTP。

这章就做一件事:把这两个面拆开,讲清楚它们各自做什么、怎么做,以及为什么必须这样分层。


第一刀:角色定位的本质差异

ApiProxy 是 Host 进程内的控制面服务——Web UI 通过它做一切事情。它管理的不只是”帮你调 Agent”,而是整个产品面:session 的创建/恢复/去重/fork、workspace 组织、agent preset 选择、settings 读写、credential 管理、model 发现与切换、审批/提问应答、Goal 管理、subagent 浏览、事件投影流(把内部 session events 变换成 UI 需要的格式推给浏览器)。

SDK 是一个进程外客户端库——TypeScript 或 Python 代码通过它驱动一个独立的 Harness 运行时子进程。SDK 的关注点极窄:启动运行时、发 prompt、收 notifications、等 idle、关闭。它不管 workspace,不管 settings UI,不管审批流——那些是 Host 产品面的事。

RpcMethodMapsession.listsession.createsession.historysession.promptsession.forksession.cancelworkspace.listworkspace.createworkspace.insertBeforesettings.describesettings.updatesettings.mutatecredentials.setcredentials.unsetllm.providersllm.modelsllm.discoverModelsagentPreset.listagentPreset.selectgoal.creategoal.editgoal.pausesubagent.listsubagent.historysubagent.prompt——这是一个完整的产品控制面。

再看 SDK server 的 handleRequest

switch (method) {
  case 'initialize':
    return this.initialize(params as unknown as InitializeParams)
  case 'session/prompt':
    return this.prompt(params as unknown as SessionPromptParams)
  case 'shutdown':
    return this.shutdown()
  default:
    throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
}

三个方法。这不是”SDK 是 ApiProxy 的子集”——它是一个完全不同的协议,面向完全不同的消费者。


第二刀:协议模型的根本不同

ApiProxy 的四象限 RPC 模型

ApiProxy 使用的不是标准 JSON-RPC 2.0。它设计了一个四象限消息模型

  1. ClientRequest — 客户端发起的调用(HTTP POST /api/<method>
  2. ServerResponse — 服务端对 ClientRequest 的回复(POST 的 HTTP 响应体)
  3. ServerRequest — 服务端发起的推送/请求(SSE 流中的帧)
  4. ClientResponse — 客户端对 ServerRequest 的回复(POST /api/respond

每条消息都带 rpcId(请求端铸造,响应端回显),type 字段做判别联合。业务错误永远走 200 + RpcResult<T>{ok: false, error} 分支——HTTP 状态码只表达传输层(404 未知路径、415 非 JSON、400 非法 body、500 handler crash)。

export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }

export interface RpcRequest<P> {
  rpcId: RpcId
  payload: P
}

export interface RpcResponse<T> {
  rpcId: RpcId
  result: RpcResult<T>
}

关键设计选择:ServerRequest 既承载可应答的交互(approval/question requested——rpcId 稳定可重放),也承载纯推送(session/event——rpcId 标识该单次推送)。是否需要响应由 method 静态决定,不存在第三种。

SDK 的标准 JSON-RPC 2.0

SDK 走的是教科书式的 JSON-RPC 2.0,line-delimited,over stdio:

export class JsonRpcLineTransport implements JsonRpcTransportPeer {
  // ...
  request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
    const id = `req_${randomUUID().replaceAll('-', '')}`
    const message = { jsonrpc: '2.0', id, method, params }
    // ...
  }
  notify(method: string, params?: object): void {
    this.write(params === undefined
      ? { jsonrpc: '2.0', method }
      : { jsonrpc: '2.0', method, params })
  }
}

id + method = request;有 idmethod = response;有 methodid = notification。格式就是 {jsonrpc: '2.0', ...}\n

对比核心

维度ApiProxySDK
协议自定义四象限 RPC(type 判别联合)标准 JSON-RPC 2.0
传输HTTP POST + SSEstdio line-delimited
双向性ServerRequest + ClientResponse(审批/提问)服务端只 notify,客户端只 request
错误模型RpcResult<T> ok/error 分支,HTTP 200JSON-RPC error response(code + message + data)
方法数40+3

第三刀:传输层——HTTP/SSE vs. 子进程 stdio

ApiProxy 的 fetch handler

ApiProxy 通过 toFetchHandler(api) 把自身包装成一个纯 WHATWG Request -> Response 函数:

export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
  return {
    async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
      const req = input instanceof Request ? input : new Request(input, init)
      const url = new URL(req.url)
      const path = url.pathname
      // SSE streams
      if (path === '/api/events.mux' && req.method === 'GET') {
        return sseResponse(api.events.mux(...))
      }
      // Unary POST routes
      const method = methodFor(path.slice('/api/'.length))
      return handleUnary(api, method, message, req.signal)
    },
  }
}

这个接口的好处很实际:toFetchHandler 返回的对象可以直接喂给 InProcessApiClient 做 in-process 测试,全程不碰网络。浏览器端的实际 client 子类只需 override doFetch,换成真实 fetch

export class InProcessApiClient extends AbstractApiClient {
  protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
    // ...uses this.handler.fetch(input, init)
  }
}

SSE 流(events.muxevents.host)使用 ReadableStream 推送 data: {...}\n\n 格式的 ServerRequest 帧。客户端通过 readSse 方法解码——不是 EventSource API,是 streaming fetch + 手动帧解析。

SDK 的子进程 stdio

SDK client 直接 spawn 一个运行时子进程,把 stdout/stdin 当作 JSON-RPC 传输:

const child = spawn(this.options.command, this.options.args ?? [], {
  stdio: ['pipe', 'pipe', 'pipe'],
})
const transport = new JsonRpcLineTransport(child.stdout, child.stdin)
transport.onNotification((method, params) => {
  this.dispatchNotification({ method, params })
})
transport.start()

子进程死亡时有一套完整的 tear-down 协议:shutdown request -> stdin EOF -> SIGTERM -> SIGKILL,由 disposeRuntimeProcess 实现。这是 SDK 独有的关注——ApiProxy 不需要管进程生命周期,它本身就活在 Host 进程里。


第四刀:Session 管理的路径差异

这是两个面最容易混淆的部分。两者都”创建 session 并发 prompt”,但机制完全不同。

ApiProxy 路径:显式创建 + 完整去重

浏览器点”新建会话”时,发送 POST /api/session.create。ApiProxy 的处理链:

  1. 检查 sessionCreations 去重 Map(防止并发重复创建)
  2. 检查 live Agent 注册表(agent 已存在?)
  3. 检查持久化层是否有可恢复的 session(resume 路径)
  4. 校验 cwd 冲突(同一 sessionId 不能换目录)
  5. 校验 preset 冲突
  6. mkdir 确保 cwd 存在
  7. composeAgent(preset) 决定挂载哪些工具和配置
  8. 最终调用 ctx.agents.create()

之后发消息走 session.prompt,独立于创建——浏览器先建好 session,再在里面发 prompt。

SDK 路径:延迟创建,prompt 即创建

SDK 的 HarnessSession 只是一个 id 持有者——构造它不产生任何网络流量:

session(sessionId?: string): HarnessSession {
  return new HarnessSession(this,
    sessionId ?? `session-${randomUUID().replaceAll('-', '')}`)
}

真正的 session 创建发生在第一次 prompt 到达 server 时:

private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
  if (this.shuttingDown) throw new Error('SDK server is shutting down')
  const existing = this.sessions.get(sessionId)
  if (existing) return existing
  const pending = this.sessionCreations.get(sessionId)
  if (pending) return pending
  const creation = this.createSession(sessionId)
  this.sessionCreations.set(sessionId, creation)
  return creation
}

注意 SDK server 的 createSession 不走 preset composition,不走 cwd conflict check,不走 workspace attach——这些都是产品面关心的事。SDK 创建 session 的路径极短:直接 ctx.agents.create 带上 provider/model/maxTokens,完毕。

对比核心

环节ApiProxySDK
创建时机显式 session.create RPC首次 session/prompt 延迟触发
去重sessionCreations Map + live check + persistence resumesessionCreations Map(简单版)
Preset composition有,选择并挂载工具/指令
Workspace attach
CWD 校验有,防冲突无(handshake 时确定一个全局 cwd)
Model 配置每 session 可切换(session.selectModelhandshake 时确定一个全局 provider/model

第五刀:事件推送机制

ApiProxy 的 SSE mux 流

ApiProxy 维护一个聚合事件流 events.mux——所有活跃 session 的事件投影到一条 SSE 连接推给浏览器。事件经过投影变换:内部的 tool/calltool/result 事件附带 ToolEventView(渲染意图),告诉前端用什么 UI 组件展示。流上还包含可应答的 ServerRequest(approval/question requested),前端通过 POST /api/respond 回复。

流的起始协议:SSE 连接建立后先发一行 : connected\n\n(注释行,确保代理/CDN 看到活字节),然后对每个已 attach 的 session 发 subscribed 控制帧,再 replay 所有 pending approval/question。

SDK 的 notification 订阅

SDK 的事件通知完全不同——服务端对每个 session event 发一个 JSON-RPC notification:

this.disposers.push(ctx.on('session/event', (session, event) => {
  const payload: SessionEventNotification = { sessionId: String(session.id), event }
  this.transport.notify('session.event', payload)
}))

客户端通过 subscribe(filter?)subscribeSessionTree(sessionId) 筛选:

subscribeSessionTree(sessionId: string): NotificationSubscription {
  return this.subscribe((notification) => {
    // ...checks parentSessionId/childSessionId lineage
    const relatedId = params.sessionId
    return typeof relatedId === 'string' && this.isDescendantOf(relatedId, sessionId)
  })
}

关键差异

  • ApiProxy 做服务端投影(决定推什么格式给浏览器),SDK 做客户端过滤(运行时推所有 session 的所有事件,客户端自己 filter)。
  • ApiProxy 有可应答帧(approval/question),SDK 没有这个概念——SDK 场景下 Agent 不会向外部用户请求审批。
  • ApiProxy 的流是有状态的(subscribed sessions),SDK 的通知是无状态广播。

第六刀:初始化与生命周期

ApiProxy 的生命周期

ApiProxy 不需要”启动”或”握手”——它是 Host 进程中 Cordis 树上的一个 Service,随 Host 启动而存在。它通过依赖注入获得 agentssessionsworkspaceRegistrytoolsllmcredentials 等十几个核心服务的引用。Web client 连上来就能调 RPC,不需要协商。

SDK 的 initialize 握手

SDK client 必须先完成 initialize 握手才能发 prompt:

async initialize(params: InitializeParams): Promise<InitializeResult> {
  this.cwd = resolve(params.cwd)
  this.provider = params.provider
  this.model = params.model
  this.maxTokens = params.maxTokens
  if (!this.hasAdapterFor(this.provider)) {
    if (this.provider !== 'deepseek-official')
      throw new Error(`no adapter registered for provider "${this.provider}"`)
    this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
  }
  return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}

这个握手做了 ApiProxy 不需要做的事:动态挂载 LLM adapter。ApiProxy 运行在 Host 里,adapter 在启动时就已经 compose 好了。SDK 运行时可能是一个精简配置,只有在 initialize 确认 provider 后才知道需要哪个 adapter——如果 deepseek-official 没有预注册,就临时 ctx.plugin(LlmDeepSeek, {})

SDK 的关闭走一个分层协议:

  1. shutdown request(有 1s timeout)
  2. stdin EOF(6s grace)
  3. SIGTERM(3s grace)
  4. SIGKILL

这又是 ApiProxy 完全不需要的——ApiProxy 随 Host 进程退出。


第七刀:客户端抽象层

ApiProxy 端:AbstractApiClient

ApiProxy 的客户端不是直接拼 HTTP request——而是通过 AbstractApiClient 抽象类:

export abstract class AbstractApiClient implements IApiClient {
  protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
  
  protected async callUnary<K extends keyof RpcMethodMap>(
    method: K, payload: RequestPayload<K>,
    signal?: AbortSignal, timeoutPolicy: UnaryTimeoutPolicy = 'default',
  ): Promise<RpcResponse<ResponseValue<K>>> {
    const message: ClientRequest = {
      type: 'client-request', rpcId: this.mintRpcId(), method, payload
    }
    this.onEnvelope(message)
    const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy)
    const full = serverResponseSchema.parse(await response.json())
    // ...rpcId echo verify, value schema parse
  }
}

它做了完整的协议工程:rpcId 铸造、信封包装/解包、zod schema 双向校验(request payload + response value)、envelope observation(microtask 批次化推给 listener)、timeout 管理(bounded vs caller-signal-only)、SSE 流解码。

InProcessApiClient 子类通过注入 toFetchHandler(api).fetch 实现零网络测试。浏览器子类 override doFetchwindow.fetch

SDK 端:HarnessClient

SDK 的 client 层完全不同——它管的是子进程生命周期 + JSON-RPC transport:

export class HarnessClient {
  private child: ChildProcess | undefined
  private transport: JsonRpcLineTransport | undefined
  
  start(): void { /* spawn child, wire transport */ }
  async initialize(params): Promise<InitializeResult> { /* handshake */ }
  async prompt(sessionId, contentBlocks): Promise<string> { /* enqueue */ }
  subscribe(filter?): NotificationSubscription { /* client-side filter */ }
  close(): Promise<void> { /* shutdown + dispose ladder */ }
}

上面再包一层高级 API DeepSeekHarness

export class DeepSeekHarness implements AsyncDisposable {
  session(sessionId?: string): HarnessSession { /* lazy id holder */ }
  run(input, options?): Promise<RunResult> { /* prompt + wait idle */ }
  close(): Promise<void> { /* terminal, no retry */ }
}

HarnessSession.run 的完整流程:

  1. 确保 handshake 完成(harness.start()
  2. subscribeSessionTree(this.id) 拿通知订阅
  3. client.prompt(this.id, contentBlocks) 发请求拿 messageId
  4. 循环 subscription.next() 等到 inbox receipt(匹配 messageId
  5. 继续收集直到 session.status = idle
  6. 返回 RunResult(finalResponse + events + notifications)

第八刀:它们共享什么

说了这么多差异,两者确实共享底层基础设施:

  1. Agent 注册表ctx.agents)—— 无论是 ApiProxy 的 session.create 还是 SDK server 的 createSession,最终都调用 ctx.agents.create()
  2. Session 持久化ctx.get('sessionPersistence'))—— session log 的写入/读取机制相同。
  3. LLM adapter—— 同一个 provider/model 路由。
  4. Subagent runtime—— SDK server 也订阅 session/createdsubagent/end 事件,推 subagent.started / subagent.finished 通知。
// SDK server 中订阅的事件与 ApiProxy 的 mux 流来源完全相同
this.disposers.push(ctx.on('session/event', (session, event) => { ... }))
this.disposers.push(ctx.on('agent/status', ({ agent, status }) => { ... }))
this.disposers.push(ctx.on('session/created', (session) => { ... }))
this.disposers.push(ctx.on('subagent/end', function (this, info) { ... }))

但共享到此为止。ApiProxy 对这些事件做投影变换(附加 ToolEventView 渲染意图、过滤到 subscribed sessions、包装成四象限 ServerRequest),SDK server 做原样广播(一条 JSON-RPC notification,原始 SessionEvent 结构,发给所有 session)。


第九刀:错误处理的设计哲学

ApiProxy:闭合错误码联合

ApiProxy 定义了一个闭合的 RpcErrorCode 联合,每个错误码有类型化的 details:

export interface RpcErrorDetailsMap {
  'bad-request': { issues: ZodIssue[] }
  'session-not-found': { sessionId: SessionId }
  'model-unavailable': { provider: string; model: string }
  'settings-conflict': { ns: string; expected: number; actual: number }
  'agent-busy': { reason: string }
  // ...30+ 错误码
}

客户端可以 switch (error.code) 做精确窄化。业务错误永远不是 HTTP 异常——它们是 200 响应体里的结构化 {ok: false, error}

SDK:JSON-RPC 标准错误 + 传输异常

SDK client 使用三个自定义 Error 类:

  • TransportClosedError — 子进程死亡或不可用
  • RequestTimeoutError — 请求超时
  • SdkProtocolError — 运行时响应不符合协议约定
  • JsonRpcResponseError — 标准 JSON-RPC error response(code + message + data)

错误处理哲学完全不同:ApiProxy 假设连接稳定(localhost HTTP),关注的是业务语义错误;SDK 假设子进程可能随时死亡,关注的是传输生存性


第十刀:一张全景对比表

维度ApiProxy (Web 控制面)SDK (客户端库)
进程位置Host 进程内 Cordis Service外部进程,spawn 子进程
消费者Web UI (浏览器)TypeScript/Python 应用代码
协议四象限 RPC (自定义)JSON-RPC 2.0 (标准)
传输HTTP POST + SSEstdio line-delimited
方法数40+3
Session 创建显式 session.create首次 prompt 延迟触发
Preset / Workspace完整支持
事件推送服务端投影 (ToolEventView)原样 notification 广播
双向交互ServerRequest + ClientResponse (审批/提问)无(单向 notify)
生命周期随 Host 进程存活显式 start/close + dispose ladder
错误模型闭合 RpcErrorCode 联合JSON-RPC error + Transport errors
Model 切换每 session 可动态切换handshake 时固定
测试策略InProcessApiClient (零网络)spawn 真实子进程

为什么要分成两个面

你可能会问:既然共享底层,为什么不让 SDK 也走 ApiProxy 的 HTTP 端点?

答案在于关注点分离部署拓扑

  1. SDK 场景不需要产品面。一个 CI 脚本驱动 Agent 跑代码审查,不需要 workspace、settings UI、审批流、model 发现。把这些塞进 SDK 协议会无意义地增加复杂度和耦合。

  2. 进程拓扑不同。ApiProxy 活在 Host 里,浏览器通过网络(localhost)连它。SDK 子进程活在调用者进程树下,stdio 是最快、最简、最可靠的进程间通道——没有端口冲突、没有 TLS 配置、没有 CORS 问题。

  3. 生命周期所有权不同。ApiProxy 不拥有任何外部进程——它被拥有。SDK client 拥有子进程——它决定何时启动、何时 shutdown、何时 kill。这种所有权反转决定了协议中必须有 initialize/shutdown 这样的生命周期方法。

  4. 事件投影 vs 原始事件。浏览器需要渲染意图(“这个 tool call 该用文件编辑器组件还是终端组件展示”),SDK 消费者需要原始事件(自己决定怎么处理)。把投影逻辑放进通用协议会破坏 SDK 消费者的灵活性。


收口:看到“面”,不要看到“层”

ApiProxy 和 SDK 不是“同一系统的不同抽象层”。它们是同一运行时露给不同消费者的两张面孔:协议不同,管理范围不同,生命周期所有权也不同。浏览器走 ApiProxy 的 HTTP/SSE 四象限协议,拿到完整的产品控制面;代码驱动走 SDK 的 stdio JSON-RPC,只要一个极简的 prompt-then-observe 接口。底层 Agent 不关心谁在驱动它,它只看到 followup(message) 和 session event 写入。两张面各自把协议工程做好,最后在 ctx.agents.create() 这一层汇合。