青雲的博客

第七部:产品面——同一运行时的不同面孔

同一个 Cordis 运行时,长出五个产品面:Headless CLI 一次性跑完、JSON-RPC Server 长期驻留、TS/Python SDK 协议共享、ApiProxy 控制面、Conversation UI 只投影不持真。

[图片占位:同一棵Cordis运行时树长出多个入口——终端CLI(复古绿)、JSON-RPC服务器(齿轮状)、SDK代码窗口(括号)、Web浏览器(多面板)、ACP自动化(机器人手臂)。每个入口共享同一根但枝叶不同。色调:彩虹棱镜效果,从根部深蓝向外辐射多彩。]
展开阅读路线与实验入口

你用 DeepSeek Harness 的方式有几种?终端敲 dsh --profile headless "写个排序"?Python 里 from deepseek_harness import DeepSeekHarness?浏览器里点开对话界面?还是编辑器插件通过 stdio 连一个常驻进程?

看起来是五个产品。但你打开进程列表看——它们不是五个不同的 Agent 实现。它们是同一棵 Cordis 运行时树上长出的五个入口,共享根容器里的 system prompt、tools、sandbox、approval,只是在入口层做了不同的包装。

第七部回答一个反直觉的问题:为什么五个入口共享同一套 AgentLoop,但你写脚本调用 SDK 和在浏览器里点按钮,体验天差地别?答案不在 Agent 里,在入口层做了什么。

这一部解决什么

读完这五章,你能分清五个产品面各自的边界在哪里、谁是事实源谁是投影、谁是一次性进程谁是常驻服务。你会知道 Headless 不是 ACP,SDK 不是简单 API wrapper,ApiProxy 不是透明代理,Conversation UI 从来不持有真相。把这些搞混,你会在 SDK 里找 Session 对象,在浏览器里找 Agent 引用,在 ApiProxy 里找模型调用代码——全部找错地方。

如果你不是线性通读,想先把产品面脑图搭起来,可以这么走:先看 Headless / Server / SDK 的入口边界,再看 ApiProxy 到底是什么,最后看 Conversation UI 为什么只是投影。第七部不难在五个名词本身,而难在别把事实源、包装层、投影层混成一团。

accTitle: 第七部阅读路径——同一运行时五个产品面
accDescr: Headless CLI 提供命令行交互,JSON-RPC Server 暴露协议,TypeScript/Python SDK 封装客户端,ApiProxy 是控制面板,Conversation UI 投影到前端界面
accDescription: 第七部阅读路径流程图,从 Cordis Root(system prompt/tools/sandbox/approval)出发,分支出五个产品面:Headless CLI(一次性任务→输出→退出)、JSON-RPC Server(长期驻留多session)、TS/Python SDK(共享协议契约)、ApiProxy(Host控制面)、Conversation UI(投影而非真相源),并展示它们之间的关系。
flowchart TB
    Root["Cordis Root\n(system prompt / tools / sandbox / approval)"]

    Root --> H["Headless CLI\n一次性任务→输出→退出"]
    Root --> S["JSON-RPC Server\n长期驻留多 session"]
    Root --> SDK["TS / Python SDK\n共享协议契约"]
    Root --> AP["ApiProxy\nHost 控制面"]
    Root --> UI["Conversation UI\n投影而非真相源"]

    S --> SDK
    AP --> UI
    H -.->|"对比"| S
    SDK -.->|"嵌入"| Other["其他应用"]

    style Root fill:#1e3a5f,stroke:#3b82f6,color:#fff
    style H fill:#065f46,stroke:#10b981,color:#fff
    style S fill:#7c2d12,stroke:#f97316,color:#fff
    style SDK fill:#581c87,stroke:#a855f7,color:#fff
    style AP fill:#1e3a8a,stroke:#3b82f6,color:#fff
    style UI fill:#831843,stroke:#ec4899,color:#fff

本部实验全部只读:grep/cat 源码文件,用 $DSH_SOURCE_DIR 指向官方固定 commit checkout。

从最小的产品面开始:Headless CLI。你以为它就是”没有 TUI 的 CLI”?它比那更极端——它连 Agent 都不长期持有。

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

Headless CLI 与 JSON-RPC Server——两种产品面

同一套 Cordis 运行时拥有两种截然不同的产品面。Headless 是"一把梭"——一条任务进、一段文本出、进程死;JSON-RPC Server 是"长驻服务"——启动后持续等待请求、按需创建 Agent、通过协议帧维持多 session 并发。本章用 Mode C 对比辨析这两者的混淆根源、各自的生命周期模型、以及何时必须分清它们。

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

混淆的根源

你在仓库里翻到两个东西:packages/bundle/headless/packages/sdk/server/。两个包都调用 agents.create()、都依赖 Cordis 上下文、都能让 Agent 跑起来回答问题。于是你产生了一个合理但错误的假设:JSON-RPC Server 就是 Headless 的”加强版”——多了网络监听而已。

这个假设隐含的心智模型是”同一个循环的不同出口”。实际上它们是两种拓扑

  • Headless:进程 = 任务。一条 task 进来,跑完,进程死。没有第二轮。
  • JSON-RPC Server:进程 = 服务。启动后不退出,持续通过 stdio 接收 JSON-RPC 请求,按 sessionId 分发给不同的 Agent,协议帧推送事件流,直到客户端发 shutdown 才关闭。

混淆它们会导致三类工程错误:在 CI 里用 JSON-RPC Server 跑一次性任务(进程不会自己死,你得显式 shutdown);在 SDK 集成里用 Headless 循环 spawn(每次冷启动 Cordis,延迟爆炸);在设计自动化管道时以为两者的输出格式相同(一个是 stdout 纯文本,一个是协议帧)。

还有一类更隐蔽的错误:以为 JSON-RPC Server 在收到 prompt 后会”跑完就退出那个 session”。不会。Agent 创建后一直存活在 sessions Map 里,直到 shutdown 或 Server dispose。这是 long-lived 的核心含义——资源不随单次请求释放。

下面逐个拆解。


Concept A:Headless CLI——一把梭

设计意图

Headless 的设计哲学是 Unix 命令:接收输入、产出输出、退出。它的用户是人类在终端敲命令,或 shell 脚本 / CI pipeline 的 $(dsh --profile headless "...") 调用。

它的全名是 @deepseek-ai/dsh-headless,在 bundle 层以 Cordis 插件形式挂载。和其他 profile(Web、Electron)一样走 runProfile() 启动。差别仅在 bundle 组合:Headless 不包含 webserver、不包含 apiproxy、不包含 frontend dist serving。

生命周期:四步走完

Headless 只有两个插件协作:headless-startup 负责解析命令行拿到 task 字符串;headless-runner 负责执行。

第一阶段:解析 task

headless-startupcmdlineArgs 服务拿到原始命令行,用 commander 解析。如果 task 为空,直接 program.error() 报错退出——不会启动 Agent。解析成功后通过 ctx.provide(HEADLESS_STARTUP_SERVICE, { task }) 把 task 发布为 Cordis 服务。

第二阶段:创建 Agent 并投递消息

headless-runner 声明 inject = ['agentDefaultModel', 'agents', 'sessions']。它的 apply() 读取 task 配置后直接调用 run() 函数:

  1. await ctx.get('loader')?.await() —— 等所有 loader siblings 挂载完。不等的话你拿到的工具注册表可能是半成品。
  2. agents.create({ sessionId, meta: { cwd }, agentOptions }) —— 生成全新 session-${randomUUID()} 作为 id,从 agentDefaultModel.currentSelection() 取 provider 和 model。注意:没有 preset 组合、没有 workspace 验证、没有去重检查。
  3. agent.followup(createUserMessage(...)) —— 投递一条 user 消息。
  4. await agent.whenIdle() —— 等 Agent 跑到空闲。在此期间 AgentLoop 正常执行:system prompt 组装、LLM 调用、tool dispatch、approval 流程一个不缺。

第三阶段:提取结果

summarize() 函数从 firstSeq 之后的事件流里找最后一条 assistant/message 事件,提取其中所有 type === 'text' block 拼接为字符串。非文本内容(tool call 卡片、图片、reasoning tokens)不会出现在输出里。

第四阶段:刷盘退出

sessions.flush(agent.session) 把 session 事件写入持久化层。然后根据 turn/end 的 reason:

  • kind === 'completed'io.exit(0)
  • 其他 → stderr 写错误信息,io.exit(1)

输出格式

stdout 只有一行(或多行)纯文本——summarize() 的拼接结果加一个换行符。stderr 只在异常时输出 dsh: <code>: <message> 格式的错误行。没有 JSON、没有协议帧、没有事件流。

这意味着你可以 result=$(dsh --profile headless "explain quicksort") 然后 echo "$result" 直接用。但你不能 parse 结构化事件、不能区分 tool output 和 final answer、不能 cancel 正在进行的 turn(只能 SIGINT 杀进程)。

退出码语义明确:0 = Agent 正常完成任务(turn/end reason 是 completed),1 = 其他情况(包括 error、max-tokens、被打断等)。这让 CI 脚本可以直接用 set -e 来判断成败,不需要 parse 输出内容。

Headless 不是”精简版 Agent”

Headless 仍然挂载完整的 dsh-base。system prompt 会组装、sandbox 能生效、approval 机制存在、bash 工具正常执行、code runtime worker thread 会启动。它只是”精简版入口”——入口只做一件事:把一条 task 喂给完整的 Agent 然后等结果。

如果 Agent 在执行过程中需要 approval(比如危险命令),approval 插件会尝试请求用户确认。但 Headless 没有 TUI 来展示对话框——行为取决于 approval 策略配置:可能超时失败、可能按预设规则自动批准或拒绝。在脚本中跑 Headless 前,务必确认 approval 策略不会卡住等待交互输入。


Concept B:JSON-RPC Server——长驻协议服务

设计意图

JSON-RPC Server 的用户不是人类,是程序——外部 SDK 客户端(Python、TypeScript、Go wrapper)。它的设计哲学是 Language Server Protocol 式的长连接协议:一个常驻进程通过 stdio 和客户端通信,客户端可以创建多个 session、向不同 session 投递 prompt、订阅事件通知、最后显式 shutdown。

它的全名是 @deepseek-ai/dsh-sdk-jsonrpc-server,以 Cordis 插件形式加载。它声明 inject = ['agents']——只需要 Agent 工厂,不需要 webserver、apiproxy 等 Web 层。

生命周期:三阶段握手

JSON-RPC Server 的协议借鉴 LSP 模式:initialize → 正常请求 → shutdown

阶段一:initialize 握手

客户端连接后发送 initialize 请求,携带 cwdprovidermodel、可选的 maxTokens。Server 解析参数、设置工作目录、检查 LLM adapter 是否已注册(如果请求的 provider 是 deepseek-official 但尚未加载,Server 会动态挂载 LlmDeepSeek 插件)。返回 { serverInfo: { name, version } }

这一步完成后,Server 准备好接受 prompt 请求。

阶段二:prompt 与事件流

客户端通过 session/prompt 方法投递消息,携带 sessionIdcontentBlocks。Server 按 sessionId 查找或创建 Agent:

  • 如果 sessions Map 里已有该 id 的记录,复用现有 Agent。
  • 如果没有,调用 agents.create() 创建新的,保存到 Map。

这就是和 Headless 的核心差异:Headless 只创建一个 Agent 然后进程死;Server 维护一个 Map<string, SessionRecord>,Agent 跨请求存活。

Agent 跑起来后,事件通过 Cordis 事件总线流出。Server 构造函数里注册了四类监听器:

  1. session/eventtransport.notify('session.event', ...) 推送 session 事件流
  2. agent/statustransport.notify('session.status', ...) 推送 Agent 状态变化
  3. session/created(带 parentSession)→ transport.notify('subagent.started', ...) 通知子 Agent 启动
  4. subagent/endtransport.notify('subagent.finished', ...) 通知子 Agent 完成

这些 notification 是 JSON-RPC notification(无 id,客户端不需要响应)。客户端可以据此渲染流式输出、跟踪 Agent 状态、管理子 Agent 树。

阶段三:shutdown 与优雅退出

客户端发 shutdown 请求。Server 执行 performShutdown()

  1. 设置 shuttingDown = true,后续的 getOrCreateSession 会直接 throw
  2. 等待所有进行中的 session creation Promise settle
  3. 遍历 sessions Map,逐个 handle.dispose() 销毁 Agent
  4. 移除所有事件监听器
  5. 如果动态挂载了 LlmDeepSeek fiber,dispose 它
  6. 返回空对象 {}

收到 shutdown 响应后,index.ts 里的 disposeAndExit() 会 flush transport、dispose root fiber、调用 exit(0)。进程至此退出。

请求分发:handleRequest

Server 的分发逻辑极简——一个 switch:

initialize → this.initialize(params)
session/prompt → this.prompt(params)
shutdown → this.shutdown()
default → throw unknown method

目前只有三个方法。这不是偶然的精简,而是 SDK 协议的设计约束:Server 只负责 Agent 会话的创建和消息投递,不负责 workspace 管理、settings 配置、credential 存储等 Host 层面的操作。那些操作属于另一套 RPC 体系(packages/host/apiproxy/ 的 Host RPC,面向浏览器前端)。

Transport 层:stdio + line-delimited JSON-RPC

JsonRpcLineTransport 把 stdin/stdout 包装成 JSON-RPC 帧的读写流。这意味着:

  • stdout 被协议占用——你不能在 Server 模式下往 stdout 写日志(Headless 可以,因为 stdout 就是它的输出通道)。
  • 传输层和业务层解耦——HarnessSdkJsonRpcServer 不知道底层是 stdio 还是 TCP 还是 Unix socket,它只和 JsonRpcTransportPeer 接口交互。
  • 帧以换行符分隔——每行是一个完整的 JSON 对象。没有 Content-Length header(LSP 的做法),没有二进制帧。简单到你可以用 readline 模块手工实现客户端。

apply() 函数把所有东西串起来:创建 transport、创建 server、注册 transport.onRequest 回调把每个进来的 JSON-RPC request 路由到 server.handleRequest。如果方法是 shutdown,在返回响应后用 setImmediate 异步执行 disposeAndExit()——确保响应帧先写出去再关进程。

并发安全:sessionCreations Map

一个微妙的设计:如果两个 session/prompt 请求携带同一个 sessionId 几乎同时到达,而该 session 尚未创建过,会不会创建两个 Agent?不会。getOrCreateSession() 维护了一个 sessionCreations: Map<string, Promise<SessionRecord>>。第一个请求触发 createSession() 并存入 pending promise;第二个请求发现 pending 存在,直接 await 同一个 promise。创建完成后 promise 从 map 中移除,record 进入 sessions map。

这保证了即使 transport 层并发分发多个请求,同一 sessionId 只会创建一个 Agent。Headless 不需要这个机制——它只有一个请求,不存在并发。


对比表:维度级差异

维度Headless CLIJSON-RPC Server
进程模型进程 = 一个任务的生命周期进程 = 服务的生命周期
Agent 数量正好一个,跑完即销毁零到多个,按 sessionId 复用
Session 管理每次新建 randomUUID,不复用客户端指定 sessionId,首次创建后续复用
通信方式命令行参数进,stdout 文本出stdio JSON-RPC 帧,双向通信
输出格式纯文本(最后一条 assistant message)结构化 notification 事件流
多轮对话不支持,进程结束就没了天然支持,同 sessionId 可多次 prompt
取消机制SIGINT/SIGTERM 杀进程协议层面可扩展(当前通过 shutdown)
启动开销每次任务冷启动 Cordis一次启动,后续请求零开销
适用场景CI 脚本、一次性问答、shell 管道SDK 集成、编辑器插件、自动化管道
inject 依赖agentDefaultModel + agents + sessionsagents(initialize 时可能动态加载 LLM)
退出方式自动退出(task 完成 or 错误)显式 shutdown 请求

共性:同一个 Agent、同一套基础设施

容易因为差异太显著而忽略共性。两者共享的关键基础设施:

  1. 同一个 agents.create() 工厂——两者创建 Agent 的 API 调用几乎一模一样。Headless 传 sessionId: SessionId(`session-${randomUUID()}`),Server 传 sessionId: SessionId(clientProvidedId)。差别只是 id 来源。两者都传 meta: { cwd }(Headless 用 process.cwd(),Server 用 initialize 时客户端提供的 cwd)。两者都传 agentOptions: { provider, model }(Headless 从 agentDefaultModel.currentSelection() 读,Server 从 initialize params 读)。

  2. 同一个 AgentLoop——创建出来的 Agent 走相同的循环:system prompt 组装 → LLM 调用 → tool dispatch → approval → 下一轮。两者没有各自的 “简化版循环”。当 Agent 需要执行 bash 命令时,Headless 和 Server 里的 Agent 走同一条 tool execution 路径。

  3. 同一个 Cordis 上下文——两者都挂载在 Cordis 树上,享有相同的 DI 和生命周期管理。Server 通过 ctx.on('session/event', ...) 监听事件,Headless 通过 agent.session.events 直接读事件数组——数据源是同一个。

  4. 同一套 session 持久化——Headless 显式调 sessions.flush()。Server 的 Agent 通过 Cordis 树的正常生命周期在 dispose 时也会触发持久化。两者的 session 事件流格式相同、写入位置相同。

  5. 同一套 invariant 机制——两个包都有 invariant.ts 伴随插件。虽然当前都注册了空的 installer(因为它们是”面”不是”核心”,没有自己需要审计的可变状态),但它们都遵循了仓库级别的 invariant 注册纪律。

这就是”同一运行时的不同面孔”这个部标题的含义:运行时一个,面孔两张。你换掉的不是 Agent、不是 LLM adapter、不是 tool registry,你换的只是进程的”外壳”——是一次性命令壳还是协议服务壳。


什么时候你必须分清它们

场景一:CI/CD 中跑一次性任务

用 Headless。dsh --profile headless "run tests and report failures" → 拿 stdout → 写到 PR comment。不要启动 JSON-RPC Server 然后发 initialize + prompt + shutdown——那是杀鸡用牛刀,而且你得自己管进程退出。

场景二:编辑器插件需要多轮对话

用 JSON-RPC Server。编辑器启动一个 server 子进程,通过 stdio 通信。用户打开多个 tab 就对应多个 sessionId。Agent 在两次用户输入之间保持存活,不需要重建上下文。

场景三:Python SDK 批量处理文件

用 JSON-RPC Server。启动一次,循环发 prompt,避免每次冷启动 Cordis 的开销。如果批量任务彼此独立,可以用不同的 sessionId 并行。

场景四:Dockerfile 里执行一条命令

用 Headless。容器跑完就退出,exit code 决定 CI 步骤成功与否。

场景五:自动化测试框架验证 Agent 行为

看你要测什么。如果测 Agent 的最终输出——Headless 足够,spawn 进程断言 stdout。如果测 Agent 的中间过程(tool call 顺序、子 Agent 启动、事件流完整性)——用 JSON-RPC Server,订阅 notification 做断言。

场景六:需要超时控制

Headless 本身没有超时参数。你可以在 shell 层用 timeout 30 dsh --profile headless "..." 包装——进程被 SIGTERM 杀死,exit code 非零。JSON-RPC Server 的超时由客户端控制:客户端可以在等待 notification 时设置自己的 deadline,超时后发 shutdown 走优雅退出路径。


shutdown 的语义差异

两者都有”退出”的概念,但语义完全不同。

Headless 的退出是隐式的。 task 跑完 → summarize()io.exit(code)。不需要外部触发,不需要协议帧,进程自行决定何时死。退出码由 Agent turn 的结束原因决定:completed → 0,其他 → 1。调用方(shell、CI)通过 $? 判断成败。

JSON-RPC Server 的退出是显式的。 客户端必须发 shutdown 请求。收到后 Server 依次做:设置 shuttingDown 标志位 → 等待所有 pending session creation → dispose 所有活跃 Agent → 移除事件监听器 → dispose 动态加载的 LLM fiber → 返回 {} 响应。响应写出后,disposeAndExit() flush transport → dispose root fiber → exit(0)

如果客户端进程崩溃导致 stdin EOF 而没发 shutdown 怎么办?transport 检测到 EOF 后触发 Cordis effect disposer,走 server.shutdown() + transport.close() 路径——功能上等同于收到了 shutdown 请求。这是一种”优雅降级”:即使客户端不合作,Server 也不会无限悬挂。


Transport 层的设计选择:为什么是 stdio JSON-RPC

你可能会问:为什么 SDK Server 不用 HTTP?为什么不用 WebSocket?

答案在 packages/host/webserver/packages/host/apiproxy/ 里——HTTP + WebSocket 那套是给浏览器前端用的 Host RPC 体系,它承载的是完整的产品 UI 交互(session 列表、workspace 管理、settings 配置、model 选择等几十个方法)。SDK Server 有意不走这条路:

  1. stdio 天然适合子进程通信——父进程 spawn 子进程,stdin/stdout 管道自动就位,不需要端口分配、不需要 HTTP 握手、不需要 CORS。
  2. 协议极简——只有三个方法。SDK 客户端不需要管理 workspace 或 settings,它只管”初始化 → 发消息 → 关闭”。
  3. 进程生命周期天然绑定——父进程退出,子进程收到管道关闭信号。不需要心跳、不需要重连逻辑。

这和 Headless 的”没有协议”形成对照:Headless 的输出通道(stdout)就是最终产物;Server 的输出通道(stdout)是协议传输层,最终产物在协议帧里。


易混淆点:Host RPC vs SDK JSON-RPC

仓库里有两套 RPC 体系,容易搞混:

Host RPCpackages/host/apiproxy/):面向浏览器前端的四象限 RPC 模型。支持 session.listsession.createsession.promptworkspace.listsettings.update 等几十个方法。通信走 HTTP POST + Server-Sent Events。有 ClientRequestServerResponseServerRequestClientResponse 四种消息类型。

SDK JSON-RPCpackages/sdk/server/):面向 SDK 客户端的精简协议。只有三个方法(initializesession/promptshutdown)加四类 notification。通信走 stdio line-delimited JSON-RPC。

两者的目标用户不同、协议复杂度不同、传输层不同。但它们底层调的都是同一个 agents 服务来创建和驱动 Agent。


容易踩的坑

坑一:在 Headless 里期望流式输出。 Headless 直接 await agent.whenIdle(),等整个 turn 跑完才输出。你看不到 token 逐个打印的效果。想流式消费?用 JSON-RPC Server 的 session.event notification。

坑二:以为 JSON-RPC Server 每个 prompt 都创建新 Agent。 不是。getOrCreateSession() 先查 this.sessions Map,有就复用。同一个 sessionId 的多次 prompt 会投递给同一个存活的 Agent。这正是多轮对话的实现机制。

坑三:在 JSON-RPC Server 里用 console.log 调试。 stdout 被协议帧占用。你的 log 会被客户端当成畸形 JSON-RPC 帧 parse 失败。调试请写 stderr 或用 Cordis logger(默认不写 stdout)。

坑四:以为 Headless 不持久化 session。 它明确调了 sessions.flush(agent.session)。每次跑都会落盘一个 JSONL 文件。CI 里长期跑会堆积。清理方案:定期删 DSH_SESSION_ROOT 下的旧文件。

坑五:试图在 Headless 里实现 cancel。 Headless 没有协议层 cancel。await agent.whenIdle() 是不可中断的(除了进程级 SIGINT 触发 Cordis 树 dispose)。如果你需要超时控制,在 shell 层用 timeout 命令包装。

坑六:把 SDK JSON-RPC 的 session/prompt 和 Host RPC 的 session.prompt 混为一谈。 名字相似但协议不同、传输不同、参数结构不同。SDK prompt 携带 contentBlocks;Host RPC prompt 走 SessionsApi['prompt'] 签名,有更复杂的 attachment / queue 语义。分隔符也不同:SDK 用 /session/prompt),Host RPC 用 .session.prompt)。

坑七:以为 JSON-RPC Server 不需要 LLM adapter 预配置。 initialize 时如果请求的 provider 不在已注册列表里,Server 只会为 deepseek-official 做动态加载。任何其他未注册的 provider 会直接 throw。如果你的 SDK 客户端要用自定义 provider,确保 Server 进程的 cordis.yml 配置里预加载了对应的 adapter 插件。


收口:一个运行时,两张面孔

回到开头的问题:为什么同一套 Cordis 运行时需要两种进程外壳?因为它们服务的消费者完全不同。

shell 用户要的是一条命令、一个结果、一个退出码。不需要握手,不需要协议,也不想管理连接。Headless 正好满足这个场景,代价是每次都要冷启动 Cordis,所以更适合低频调用。

SDK 客户端要的是一次启动、多次交互、结构化事件流和显式生命周期控制。它不能接受反复冷启动,也不能只拿纯文本输出,还可能要并发管理多个 session。JSON-RPC Server 满足的是这类需求,代价是调用方必须实现协议客户端并接管进程生命周期。

flowchart LR
    subgraph Runtime["Cordis Runtime (dsh-base)"]
        AG["agents service"]
        SS["sessions service"]
        LLM["LLM adapters"]
        TOOLS["tool registry"]
    end

    subgraph Headless["Headless CLI"]
        H1["parse task from cmdline"]
        H2["agents.create (one agent)"]
        H3["followup → whenIdle"]
        H4["summarize → stdout → exit"]
    end

    subgraph Server["JSON-RPC Server"]
        S1["listen stdio transport"]
        S2["initialize handshake"]
        S3["session/prompt → getOrCreateSession"]
        S4["notify events to client"]
        S5["shutdown → dispose all"]
    end

    Runtime --- Headless
    Runtime --- Server

    style Headless fill:#1e3a5f,stroke:#60a5fa,color:#fff
    style Server fill:#3b1f2b,stroke:#f472b6,color:#fff
    style Runtime fill:#1a2e1a,stroke:#4ade80,color:#fff

Headless 是 task → answer → exit。JSON-RPC Server 是 start → (initialize → prompt* → shutdown) → exit。两者不是前者的简化版和后者的增强版关系,而是面向不同消费者的两种产品形态——shell 用户 vs 程序化 SDK。

理解这个区别后,你就能解释仓库里看似冗余的设计:为什么有两个包都调 agents.create()、为什么 Headless 不复用 SDK Server 的 transport、为什么 Server 不内嵌 Headless 的 summarize 逻辑。答案是它们面向的消费者对”一次交互”的定义根本不同——shell 脚本认为”进程就是交互”,SDK 客户端认为”协议帧才是交互”。

你选择哪个,取决于一个问题:你的调用方是会退出的脚本,还是会一直活着的程序? 前者用 Headless,后者用 JSON-RPC Server。没有中间状态。