ApiProxy 与 SDK:Web 的控制面和客户端协议
ApiProxy 是 Web UI 的 HTTP/SSE 网关控制面,管理 session 生命周期、事件投影、设置和审批流;SDK 是外部客户端库,驱动同一运行时的 JSON-RPC stdio 协议。两者面向不同消费者,共享底层 Agent/Session 基础设施,但协议层、传输层、生命周期管理完全不同。本章对比辨析两者在架构定位、协议设计、session 管理路径上的关键差异。
你打开浏览器用 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 产品面的事。
看 RpcMethodMap:session.list、session.create、session.history、session.prompt、session.fork、session.cancel;workspace.list、workspace.create、workspace.insertBefore;settings.describe、settings.update、settings.mutate;credentials.set、credentials.unset;llm.providers、llm.models、llm.discoverModels;agentPreset.list、agentPreset.select;goal.create、goal.edit、goal.pause;subagent.list、subagent.history、subagent.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。它设计了一个四象限消息模型:
- ClientRequest — 客户端发起的调用(HTTP POST
/api/<method>) - ServerResponse — 服务端对 ClientRequest 的回复(POST 的 HTTP 响应体)
- ServerRequest — 服务端发起的推送/请求(SSE 流中的帧)
- 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;有 id 无 method = response;有 method 无 id = notification。格式就是 {jsonrpc: '2.0', ...}\n。
对比核心:
| 维度 | ApiProxy | SDK |
|---|---|---|
| 协议 | 自定义四象限 RPC(type 判别联合) | 标准 JSON-RPC 2.0 |
| 传输 | HTTP POST + SSE | stdio line-delimited |
| 双向性 | ServerRequest + ClientResponse(审批/提问) | 服务端只 notify,客户端只 request |
| 错误模型 | RpcResult<T> ok/error 分支,HTTP 200 | JSON-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.mux 和 events.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 的处理链:
- 检查
sessionCreations去重 Map(防止并发重复创建) - 检查 live Agent 注册表(agent 已存在?)
- 检查持久化层是否有可恢复的 session(resume 路径)
- 校验 cwd 冲突(同一 sessionId 不能换目录)
- 校验 preset 冲突
mkdir确保 cwd 存在composeAgent(preset)决定挂载哪些工具和配置- 最终调用
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,完毕。
对比核心:
| 环节 | ApiProxy | SDK |
|---|---|---|
| 创建时机 | 显式 session.create RPC | 首次 session/prompt 延迟触发 |
| 去重 | sessionCreations Map + live check + persistence resume | sessionCreations Map(简单版) |
| Preset composition | 有,选择并挂载工具/指令 | 无 |
| Workspace attach | 有 | 无 |
| CWD 校验 | 有,防冲突 | 无(handshake 时确定一个全局 cwd) |
| Model 配置 | 每 session 可切换(session.selectModel) | handshake 时确定一个全局 provider/model |
第五刀:事件推送机制
ApiProxy 的 SSE mux 流
ApiProxy 维护一个聚合事件流 events.mux——所有活跃 session 的事件投影到一条 SSE 连接推给浏览器。事件经过投影变换:内部的 tool/call 和 tool/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 启动而存在。它通过依赖注入获得 agents、sessions、workspaceRegistry、tools、llm、credentials 等十几个核心服务的引用。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 的关闭走一个分层协议:
shutdownrequest(有 1s timeout)- stdin EOF(6s grace)
- SIGTERM(3s grace)
- 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 doFetch 为 window.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 的完整流程:
- 确保 handshake 完成(
harness.start()) subscribeSessionTree(this.id)拿通知订阅client.prompt(this.id, contentBlocks)发请求拿messageId- 循环
subscription.next()等到 inbox receipt(匹配messageId) - 继续收集直到
session.status=idle - 返回
RunResult(finalResponse + events + notifications)
第八刀:它们共享什么
说了这么多差异,两者确实共享底层基础设施:
- Agent 注册表(
ctx.agents)—— 无论是 ApiProxy 的session.create还是 SDK server 的createSession,最终都调用ctx.agents.create()。 - Session 持久化(
ctx.get('sessionPersistence'))—— session log 的写入/读取机制相同。 - LLM adapter—— 同一个 provider/model 路由。
- Subagent runtime—— SDK server 也订阅
session/created和subagent/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 + SSE | stdio 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 端点?
答案在于关注点分离和部署拓扑:
-
SDK 场景不需要产品面。一个 CI 脚本驱动 Agent 跑代码审查,不需要 workspace、settings UI、审批流、model 发现。把这些塞进 SDK 协议会无意义地增加复杂度和耦合。
-
进程拓扑不同。ApiProxy 活在 Host 里,浏览器通过网络(localhost)连它。SDK 子进程活在调用者进程树下,stdio 是最快、最简、最可靠的进程间通道——没有端口冲突、没有 TLS 配置、没有 CORS 问题。
-
生命周期所有权不同。ApiProxy 不拥有任何外部进程——它被拥有。SDK client 拥有子进程——它决定何时启动、何时 shutdown、何时 kill。这种所有权反转决定了协议中必须有
initialize/shutdown这样的生命周期方法。 -
事件投影 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() 这一层汇合。