青雲的博客
深入浅出 DeepSeek Harness 第一部:启动——从命令到运行时 第 05 章

为什么 Web 新建会话不是 CLI 的前端按钮

Headless 入口直接调用 agents.create(),Web 端通过 ApiProxy 走 HTTP/WebSocket。ApiProxy 是 Host 控制面,不是简单转发。Client Runtime 按领域投影状态,不直接持有 Session。dsh web 是 --profile web 的别名,但走的是 web app bundle,在 Host 进程里跑一切,浏览器只是个渲染终端。

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

你在浏览器界面点了”新建会话”按钮。你可能以为这就是 CLI createAgent() 的前端版本——按钮一点,浏览器里 new 一个 Session,new 一个 Agent,开始跑。不是这样的。浏览器里没有 Cordis,没有 AgentRegistry,没有 SessionStore,没有 LLM client。它连 Node.js 都没有。

浏览器发一个 JSON-RPC POST 请求到 Host 进程的 /api/rpc 接口,方法名 session.create。Host 进程收到后在自己的 Cordis 容器里做权限检查、preset 组合、cwd 校验、workspace 归属、并发去重,然后才调用 ctx.agents.create()。Session 在 Host 进程里,Agent 在 Host 进程里,模型调用从 Host 进程发出。浏览器只是一个渲染终端加输入设备。

Headless 入口:直接调 agents.create()

Headless profile 的 bundle 列表是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']。boot 完成后,headless 入口直接拿到 ctx.agents,调用 agents.create()agents.createAgent(),Agent 在当前进程里开始跑。命令行参数通过 ctx.cmdlineArgs 传入,prompt 从 stdin 或命令行读取,结果输出到 stdout。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '114,117p' "$repo/packages/boot/app-boot/src/profile.ts"

这是同进程调用,没有网络,没有序列化,没有 RPC 层。你拿到的是 Agent 对象的直接引用,你可以直接调 agent.prompt()、agent.cancel()、订阅 agent 的事件。这是最简单的路径——进程内直接调用。

Web 入口:ApiProxy 控制面

Web profile 的 bundle 列表是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']。dsh-web-app bundle 做什么?它不创建 Agent,它启动 HTTP 服务器(webServer),挂载前端静态文件服务(frontend-static),注册 ApiProxy,注册 WebSocket 事件流,然后打印一行 URL dsh web: http://127.0.0.1:PORT。

ApiProxy 不是简单的请求转发器。它是 Host 的控制面——所有从浏览器来的操作都经过它,它做校验、做投影、做去重、做授权、做错误翻译。你新建会话的 RPC 到了 ApiProxy 里,不是直接 ctx.agents.create() 那么简单。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '2167,2220p' "$repo/packages/host/apiproxy/src/api-proxy.ts"

sessions.create 的实现:先解析 workspaceId(如果有),workspace 不存在直接返回 workspace-not-found 错误;确定 cwd(workspace.path > 请求的 cwd > 默认 cwd);确定 requestedPreset;然后调 ensureSession()。ensureSession 里做了一堆事:检查这个 sessionId 是否已经有 live Agent(有就直接返回)、检查是否已经持久化(有就 resume 而不是 create)、检查 cwd 冲突、检查 preset 冲突、mkdir cwd、composeAgent(根据 preset 决定挂载哪些工具)、最后才调 ctx.agents.create()

flowchart TD
    A["浏览器点击 New Session"] --> B["HTTP POST /api/rpc session.create"]
    B --> C["ApiProxy.sessions.create()"]
    C --> D{"workspaceId?"}
    D -->|有| E["查 workspaceRegistry,不存在返错"]
    D -->|无| F["用默认 cwd"]
    E --> G["确定 cwd"]
    F --> G
    G --> H["ensureSession(sessionId, cwd)"]
    H --> I{"sessionCreations 去重 Map?"}
    I -->|已有同 id 创建中| J["await 同一个 Promise"]
    I -->|没有| K["检查 live Agent → 有就返回"]
    K --> L{"持久化里有?"}
    L -->|有| M["cwd 校验 + preset 校验 → resume"]
    L -->|没有| N["mkdir cwd + composeAgent(preset)"]
    M --> O["ctx.agents.resume()"]
    N --> P["ctx.agents.create()"]
    J --> Q["返回 Agent"]
    O --> Q
    P --> Q
    Q --> R["ok response { sessionId, agentPreset }"]
    R --> S["浏览器拿到 sessionId → WebSocket 订阅 mux 流"]

Client Runtime 按领域投影状态

浏览器里的 dsh-client-runtime 不持有 Session 对象。它也不可能持有——Session 是 Host 进程里 Cordis 容器里的 JavaScript 对象,浏览器根本看不到。

Client Runtime 维护自己的状态 store。它通过 HTTP RPC 获取初始数据(session.list、session.history),通过 WebSocket mux 流接收增量事件(session/event、session/projection、session/queue、approval/requested 等),然后按领域(sessions、conversation、workspaces、jobs、settings)把这些 wire 事件投影到自己的本地状态快照。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1-16p' "$repo/packages/client/runtime/src/client/sessions/service.ts"

看 SessionRuntime 的注释:“list snapshot store (manager projection; carries current, the persisted selection every session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin Fiber + ctx.extend scope tag; one scope per session, agent id === session id), stable SessionBinding cache, breadcrumb-route projection.”

它有一个 SessionSummary 接口,是从 Host 返回的 wire 数据投影来的——id、title、displayTitle、cwd、agentPreset、parentId、running、blank、updatedAt、projectionValues。这不是 Session 对象,这是 Session 的摘要视图。真正的 Session(带事件日志、工具调用状态、LLM 引用)在 Host 里。

apps/web/main.ts 只有 10 行

你去看浏览器端的入口 apps/web/src/main.ts,它只有 10 行。它不组装 Cordis,不创建 Session,不连接模型。它只做一件事:new AppWebEntry(el).run()

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
cat "$repo/apps/web/src/main.ts"

AppWebEntry 来自 @deepseek-ai/dsh-client-web,这是客户端的 shell 库。它负责建立 HTTP/WebSocket 连接、初始化 Client Runtime、挂载 React 组件树。但它不拥有 Agent 生命周期,不做业务决策——那是 Host 的事。注释写得很明白:“Everything — loader holding, module-table seeding, AppRoot gate, plugin assembly — lives in @deepseek-ai/dsh-client-web; this file only finds the mount point.”

dsh web 是 —profile web 的别名但走的是 web bundle

dsh web 命令确实是 dsh --profile web 的硬编码别名——在 args.ts 里做了个 argv 替换。但它和 dsh --profile headless 的区别不只是”有 UI 和没 UI”。

两个 profile 加载不同的 bundle 列表:

  • headless: dsh-base + dsh-headless → 直接 stdin/stdout 交互,进程内直接跑 Agent
  • web: dsh-base + dsh-web-app → 启动 HTTP 服务器、ApiProxy、静态文件服务、WebSocket 流,Agent 在 Host 进程里由 ApiProxy 管理

Web 路径上,Session 由 Host 管理,不是由浏览器直接操作。浏览器不能创建 Session、不能直接调 model、不能访问文件系统——所有这些操作都必须经过 ApiProxy 的 RPC 接口,ApiProxy 在 Host 侧做权限检查和输入校验。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '156,169p' "$repo/apps/cli/src/args.ts"

ApiProxy 的投影:不是转发,是变换

ApiProxy 不只是把浏览器请求翻译成 Host 调用,它还做大量的投影工作:

  • session.list:合并内存中 attached sessions 的实时状态和持久化中 cold sessions 的元数据,对 cold session 做 blank probe(只读元数据,不加载整个日志),生成 SessionSummary 列表。
  • session.history:分页,对齐消息边界(不切断消息组),通过 viewFor 为 tool/call 和 tool/result 事件生成 UI 视图(presenter 模式),附带 projection 基线。
  • session.create:做 workspace 归属、cwd 冲突检测、preset 冲突检测、并发创建去重(sessionCreations Map),错误翻译成稳定的 wire 错误码。
  • 事件流:通过 WebSocket mux 推送 session/event 帧,同时附带 host-level 事件(session created/destroyed、agent failures)、approval/question 帧(需要用户交互的暂停点)、queue 快照、projection 变更帧。

这不是一个透明代理。这是一个领域网关——它理解 Session 的生命周期、Agent 的状态、Workspace 的归属关系,它把 Host 内部的复杂对象图变换成浏览器能消费的 JSON 事件流。

容易踩的坑

坑一:以为 Web 新建会话是 CLI 的前端按钮。 不是。CLI headless 直接同进程调用 agents.create(),Web 通过 HTTP RPC 到 ApiProxy,ApiProxy 做一堆校验和去重后才在 Host 进程里创建。浏览器里没有 Agent。

坑二:以为 ApiProxy 是透明转发。 它做 workspace 归属检查、cwd/preset 冲突检测、并发去重、session 状态合并(live + cold)、错误码翻译、presenter 视图生成。如果你绕过 ApiProxy 直接在 Host 里操作 Session,Web UI 不会知道——它只订阅 ApiProxy 广播的事件。

坑三:以为浏览器里有 Session 对象。 Client Runtime 只有 SessionSummary 投影和从事件流组装的 conversation 视图。没有日志、没有工具注册表、没有 LLM 连接。所有操作都是 RPC。

坑四:以为 dsh web 和 dsh —profile headless 只差个 UI。 Bundle 列表不同,启动的服务不同,交互模式不同(请求-响应+WebSocket流 vs stdin/stdout),会话管理不同(ApiProxy 管控 vs 直接控制),安全边界不同(浏览器不能直接访问文件系统)。

坑五:以为前端可以直接创建任意 session。 ApiProxy 的 session.create 校验 cwd 必须是绝对路径、校验 workspace 归属、校验 preset 一致性(已存在 session 的 preset 不能变),这些校验在 Host 侧做,前端绕不过去。

第一部讲完了。从 dsh 命令敲下,到 bin.ts 分发,到五层 patch 叠出配置树,到 Cordis 容器自组装,到 Agent/Session 的事务创建,到 Web 和 Headless 两条路径的分岔。你已经知道控制权是怎么从命令行一步步交到运行时手里的。第二部会进入运行时内部——Agent Loop 是怎么驱动模型对话的,工具是怎么注册和调用的,Session 事件流是怎么工作的。