Agent 和 Session 共用一个身份证——但 Headless 和 Web 走的不是同一条路
你以为 Agent 和 Session 是两个独立实体各有各的 ID,或者以为 Web 模式只是"CLI 加了个浏览器壳"。实际上 Agent 和 Session 共享同一个 SessionId 作为身份标识,而 Headless 和 Web 的创建路径从根本上不同——不是 UI 层的差异,是生命周期模型的差异。
混淆的根源
你看文档和代码,到处都会碰到两个词:Agent 和 Session。直觉上它们像两个东西:Agent 是“做事的人”,Session 是“会话容器”。如果再套用 Web 框架经验,很容易想象出两套 ID:Agent 有 agentId,Session 有 sessionId,两者靠某个外键关联。
但 AgentRegistry.enter() 里有一行断言:agent.id === agent.session.id。Agent 没有自己独立的 ID。它的身份就是它所在 Session 的 ID。AgentRegistry 的内部 store 是 Map<SessionId, AgentEntry>,键是 SessionId,不是什么 AgentId。在 DSH 的模型里,Agent 和 Session 是同一枚硬币的两面,不是两个独立实体。
为什么会有这个设计?因为在 DSH 里,一个”对话”就是一个 Agent 的全部执行历史。没有”一个 Agent 参与多个 Session”的场景,也没有”一个 Session 里换了好几个 Agent”的场景。一对一,从生到死。既然如此,维护两套 ID 就是纯粹的复杂度浪费。
第二个混淆更隐蔽:你以为 Web 模式就是”Headless 模式加了个浏览器前端”。毕竟最终都是创建 Agent、跑 turn、返回结果,UI 只是展示层嘛。就像 Express 应用加了个 React 前端——后端逻辑不变,前端只是调 API 然后渲染。
这个直觉也是错的。Headless 是一次性的——解析命令行 task、创建一个 agent、跑完、打印、退出。Web 是长驻的——绑定端口、等请求、每个对话按需创建 agent、永远不主动退出。它们共用 AgentRegistry.create() 这个底层 API,但谁调、什么时候调、调完之后的生命周期怎么走完全不同。
这不是 UI 层的差异。这是进程模型的差异。一个是 batch job,一个是 daemon。你不会说 cron 脚本和 nginx “是同一个东西只是 UI 不同”吧?
这两个混淆的共同根源是:你把 DSH 的 Agent 系统类比成了传统 Web 框架的 Controller/Service 模式。在那个模型里,Controller 是 Controller、Service 是 Service、HTTP Server 只是个 transport layer。但 DSH 不是 Web 框架。它是一个有状态的 Agent 运行时,Agent 的身份绑定、创建时机、生命周期终结方式都是第一等概念。你必须分清这些概念才能理解后面的 turn 循环、事件流、crash recovery。
接下来我们从源码出发,先看 Agent 和 Session 的身份共享机制,再看 Headless 和 Web 两条启动路径的具体实现。
概念 A:Agent 和 Session 的共享身份
AgentRegistry 是什么
AgentRegistry 继承自 Service,注册名是 agents——注意是复数。你通过 ctx.agents 访问它。它的核心数据结构极其简单:
store = new Map<SessionId, AgentEntry>()
键是 SessionId,值是 AgentEntry。没有 AgentId 这个类型。整个系统里 Agent 的唯一标识就是它所属 Session 的 ID。
create 和 register 的分工
AgentRegistry.create(options: CreateAgentOptions) 不直接实例化 Agent。它委托给一个 factory(通常是 AgentLoop)。factory 负责实际的构造工作——创建 Session、初始化 Agent 实例、准备上下文。
构造完成后,AgentRegistry.register(agent) 接手。register 做的事是一个 generator effect(还记得上一章说的 generator effect 自动回滚吗?):先 enter(agent),然后 announce(agent)。如果 announce 失败,enter 的 disposer 自动执行,agent 不会留在 store 里。
enter 的身份断言
enter(agent, owner) 是整个身份模型的关键。它做的第一件事就是验证 Agent 的 id 就是它 session 的 id:
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "agent.id === agent.session.id\|agent\.id.*session" "$repo/packages/core/agent/src/index.ts" | head -5
这不是冗余校验。这是一个设计约束的运行时保证:在 DSH 里,Agent 不能”跳槽”到另一个 Session,也不能有两个 Agent 共享同一个 Session。一个 SessionId 对应恰好一个 Agent entry,反之亦然。
enter 做的第二件事是把 agent 存入 store:this.store.set(agent.id, entry)。第三件事是设置 ctx 上的 agent 访问器——让 agent.ctx.agent 指向自己。
ctx.agent vs ctx.agents
这两个访问器容易搞混:
ctx.agents(复数):指向 AgentRegistry service 本身。你用它来 create、查询、枚举所有 agent。ctx.agent(单数):在 Agent 的 context 上才有值。在普通的 plugin context 上是 undefined。它是enter()设置的 own property。
验证一下:
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "ctx\.agent\b" "$repo/packages/core/agent/src/index.ts" | grep -v "agents" | head -10
这意味着你在一个普通 plugin 里不能随便 ctx.agent 拿当前 agent——只有在 Agent 自己的 context 里才有。如果你需要知道”是谁触发了当前操作”,那是另一个机制:InitiatorScope。
InitiatorScope:谁触发了这件事
DSH 有很多操作跨越 Agent 边界——一个 parent agent 创建 sub-agent,sub-agent 调用工具触发另一个 agent 的方法。你需要追踪”是谁发起的”。
InitiatorScope 基于 AsyncLocalStorage。withInitiator(agent, operation) 把 agent 存入 ALS,在 operation 的整个 async 调用链里,任何地方都能通过 getInitiator() 拿到发起者。withoutInitiator(operation) 显式清除追踪——用于系统级操作不需要归属到任何 agent 的场景。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "AsyncLocalStorage\|withInitiator\|withoutInitiator" "$repo/packages/core/agent/src/index.ts" | head -8
为什么要共享身份
你可能会问:为什么不给 Agent 一个独立 ID?答案是简化。Session 已经有唯一 ID 了,它标识了一次完整的对话上下文(包括所有 events、messages、tool calls)。Agent 是这个对话上下文的”执行者”。如果给 Agent 一个独立 ID,你就必须维护 AgentId → SessionId 的映射,每次查 session events 都要先翻译 ID。共享身份消除了这层间接性——拿到 agent,直接用 agent.id 查 session store,零成本。
这也意味着 Agent 和 Session 的生命周期是完全绑定的:Session 被 dispose,Agent 就没了;Agent 没了,Session 也没用了。不存在”Agent 死了但 Session 还活着”的状态。不存在”Session 还在但 Agent 换了一个”的状态。这个一对一的绑定让整个系统的状态空间大幅缩小——你不需要考虑”半死不活”的组合。
对比一下传统 Web 框架:User 和 Session 可以是多对多的——一个用户可以有多个活跃 Session(多设备登录),一个 Session 理论上可以在用户之间转移(虽然通常不这么做)。这种灵活性带来了大量的边界条件和安全问题。DSH 选择了更严格的模型:一个 Agent 就是一个 Session,永远如此,没有例外。
另一个好处是 debug 时的可追溯性。当你在日志里看到一个 SessionId,你立刻知道它对应哪个 Agent;当你在 telemetry 里看到一个 Agent 的操作记录,你立刻知道去哪个 Session store 里找完整上下文。不需要 JOIN 两张表。
概念 B:Headless 一次性 vs Web 长驻服务
现在我们理解了 Agent 和 Session 的身份共享,接下来看第二个混淆点:两条启动路径。
DSH 有两个 bundle——packages/bundle/headless 和 packages/bundle/web-app。它们各自有一个 startup plugin 和一系列 consumer plugin。startup plugin 负责解析启动参数并 provide 一个 service;consumer plugin inject 这个 service 然后执行实际逻辑。这是 Cordis 的标准模式:数据提供者和数据消费者通过 service 声明解耦。
但这两个 bundle 的设计意图截然不同。一个是”跑完就退”,一个是”开着不关”。
Headless 启动路径
打开 packages/bundle/headless/src/startup.ts。这个 plugin 叫 'headless-startup',声明 inject: ['cmdlineArgs']。它的职责极其明确,三步完事:
- 从命令行参数解析 task 文本(positional argument)——就是你在终端里
dsh "帮我写个函数"后面那个字符串 - 提供
HEADLESS_STARTUP_SERVICE,值是{ task: string } - 完。没有端口绑定,没有路由注册,没有安全检查。
为什么这么简单?因为 Headless 的安全模型是”用户直接在终端里打命令”——你已经有了机器的 shell 权限,不需要额外的认证层。task 就是你说的话,不需要 JSON 序列化、不需要请求头、不需要 session token。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1,45p' "$repo/packages/bundle/headless/src/startup.ts"
注意它做的事有多简单:解析一个字符串,provide 一个 service。它甚至不自己创建 Agent——那是另一个 consumer(headless runner)的事。runner inject 了 headlessStartup 这个 service,拿到 task 文本后调用 ctx.agents.create({ task }) 创建一个 agent,等 agent 完成,打印结果,然后 ctx.scope.dispose() 退出整个进程。
这个设计很精巧:startup plugin 只负责”把命令行参数变成 service”,runner plugin 只负责”消费 service 然后驱动 agent”。两者通过 Cordis 的 inject 依赖声明连接。如果 startup 解析失败(比如没传 task 参数),runner 永远不会被激活——因为它 inject 的 service 不存在,fiber 停在 PENDING。
这是一个一次性的生命周期:启动 → 解析 task → 创建 agent → 执行 → 打印 → dispose → exit。没有循环,没有等待下一个请求。进程结束就是 agent 生命的终结。整个进程就是为了这一个 task 而存在的。
用图来理解:
[终端] ──task字符串──→ [headless-startup] ──provide──→ [headless-runner]
│
agents.create()
│
Agent 执行 turn
│
打印结果 → exit
线性,单向,不可逆。
Web 启动路径
打开 packages/bundle/web-app/src/startup.ts。这个 plugin 叫 'web-startup',同样 inject ['cmdlineArgs']。但它做的事完全不同:
- 解析
--host、--port、--trusted-host参数 - 显式拒绝
--host 0.0.0.0——这是安全措施,不让你意外暴露到公网 - 提供
WEB_STARTUP_SERVICE,值是{ host?, port?, trustedHosts[] } - 完。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1,50p' "$repo/packages/bundle/web-app/src/startup.ts"
和 Headless 一样,Web startup 本身不创建 Agent。但后面的 consumer 完全不同。Web 的 consumer 是 ApiProxy(一个 JSON-RPC server),它绑定到 host:port,然后等着。每当有客户端发起新对话,ApiProxy 通过 JSON-RPC 接口调 ctx.agents.create()。一个 Web 进程可以同时存在多个 Agent——每个对话窗口一个。
这是一个长驻的生命周期:启动 → 绑定端口 → 等待请求 → 按需创建/销毁 agent → 直到被 SIGTERM 才退出。没有”做完一个任务就退出”的概念。进程的生命周期和任何单个 Agent 的生命周期完全解耦。
用图来理解:
[浏览器 A] ──JSON-RPC──→ [ApiProxy] ──agents.create()──→ Agent A
[浏览器 B] ──JSON-RPC──→ [ApiProxy] ──agents.create()──→ Agent B
[浏览器 C] ──JSON-RPC──→ [ApiProxy] ──agents.create()──→ Agent C
│
绑定 host:port 长驻
每个 Agent 独立生命周期
Agent A dispose 不影响 B、C
多路复用,持续运行,按需伸缩。
另一个关键区别:Web 模式的 task 来源是动态的。用户在浏览器里打字,每条消息都是一个新的 JSON-RPC 请求。第一条消息触发 agents.create(),后续消息通过已有 Agent 的 inbox 投递。而 Headless 的 task 在进程启动那一刻就定死了——它来自命令行参数,不可能在运行时变化。
核心差异:谁调 create,什么时候调
两条路的底层是同一个 AgentRegistry.create() API。factory 一样,Agent 实例一样,Session 一样,身份绑定一样。但:
| 维度 | Headless | Web |
|---|---|---|
| 谁调 create | headless runner(内部 consumer) | ApiProxy(响应客户端请求) |
| 什么时候调 | 进程启动后立即,只调一次 | 每次收到”新对话”请求时调 |
| 同时存在几个 Agent | 恰好 1 个(加可能的 sub-agents) | 0 到 N 个独立顶层 agent |
| Agent 生命周期 | 等于进程生命周期 | 独立于进程,可被单独 dispose |
| 进程退出时机 | Agent 完成后立即退出 | 从不主动退出(除非 SIGTERM) |
| task 来源 | 命令行参数(编译时确定) | JSON-RPC 请求体(运行时动态) |
| 安全约束 | 无网络暴露 | trustedHosts 白名单 + 拒绝 0.0.0.0 |
| 多轮对话 | 不支持(one-shot) | 原生支持(同一 Agent 接收多条消息) |
| Session 恢复 | 不需要 | 核心功能(crash recovery) |
| 并发 Agent 资源竞争 | 不存在 | 必须考虑 |
这张表的核心信息是:create 是同一个函数,但调用方的意图、时机、和后续行为完全不同。就像 malloc 和 malloc 是同一个函数,但在 one-shot CLI tool 里调一次就退出和在 long-running server 里反复调用需要完全不同的内存管理策略。
注意表里的”多轮对话”行:Headless 是 one-shot,Agent 收到 task 后跑完所有 turn 直到 settlement,然后退出。不存在”用户看完回答后追问”的概念。Web 原生支持多轮——第一条消息创建 Agent,后续消息通过 inbox 追加到同一个 Session,Agent 继续响应。这不是功能缺失,而是设计选择:Headless 针对的是 CI/CD、脚本自动化、单次任务执行的场景。
还有”Session 恢复”行:Headless 不需要 crash recovery 是因为用户的重试策略是”重跑整个命令”。Web 需要 crash recovery 是因为用户的期望是”刷新浏览器后对话还在”。这两种用户期望决定了完全不同的持久化策略。
验证一下两条路确实调的是同一个 create:
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
# AgentRegistry.create 的签名
grep -n "create(" "$repo/packages/core/agent/src/index.ts" | grep -v "//" | head -3
验证两条路的分离
你可以用一个实验确认这两个 startup service 确实是互斥的——它们在不同的 bundle 里,不会同时被加载:
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
# headless bundle 的 cordis.yml 里引用了 headless-startup
grep -r "headless-startup\|headless/src/startup" "$repo/packages/bundle/headless/" | head -5
echo "---"
# web-app bundle 的 cordis.yml 里引用了 web-startup
grep -r "web-startup\|web-app/src/startup" "$repo/packages/bundle/web-app/" | head -5
两个 bundle 是独立的入口。你不会同时跑 headless 和 web——它们是两个二进制(或两个 yarn workspace script),各有各的 cordis.yml 组合。共享的部分(AgentRegistry、AgentLoop、Session store 等)在 packages/core/ 里,被两个 bundle 共同依赖。
0.0.0.0 的安全拒绝
Web startup 有一个值得单独拎出来说的细节:它显式检查 --host 参数,如果是 0.0.0.0 就直接报错退出。为什么?
因为 DSH Web 模式默认绑定 127.0.0.1(或 localhost)。这意味着只有本机能连。如果你传 0.0.0.0,任何网络上的人都能连到你的 Agent——而 Agent 有文件读写、命令执行的能力。这不是”加个认证就行”的问题,而是 DSH 在设计层面就不允许这种配置存在。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "0\.0\.0\.0" "$repo/packages/bundle/web-app/src/startup.ts"
你必须区分它们的场景
理论讲完了,下面是五个你在实际开发和调试中会碰到的场景。每个场景里,搞混 Headless 和 Web 都会让你走弯路。不是”理解了就好”的知识,而是”搞混了就浪费半天”的区分。
场景一:调试 Agent 创建流程
当你追踪”Agent 是怎么被创建的”这个问题时,你必须先确定你在哪条路上。如果你在 headless 模式下调试,创建入口在 headless runner 里——搜 headlessStartup inject 就能找到。如果你在 web 模式下调试,创建入口在 ApiProxy 的 JSON-RPC handler 里——搜 agents.create 在 web-app 或 api-proxy 包里的调用。
两条路的 callstack 完全不同。如果你在 headless runner 里打断点,web 模式的创建永远不会经过那里。反之亦然。
一个实际的调试策略:先看你跑的是哪个 bundle(packages/bundle/headless 还是 packages/bundle/web-app),然后在对应的 cordis.yml 里找到 runner plugin 的路径,在那里设断点。不要在 AgentRegistry.create 里设断点后困惑”为什么 callstack 里没有 HTTP handler”——因为你跑的是 headless。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
# 找到 headless bundle 的 runner(消费 HEADLESS_STARTUP_SERVICE 的 plugin)
grep -rn "HEADLESS_STARTUP_SERVICE\|headlessStartup" "$repo/packages/bundle/headless/src/" | head -5
echo "==="
# 找到 web bundle 里调 agents.create 的位置
grep -rn "agents\.create\|agents\.create" "$repo/packages/bundle/web-app/src/" "$repo/packages/core/api-proxy/src/" 2>/dev/null | head -5
场景二:理解 Session 持久化和恢复
Headless 模式下,Session 的生命周期等于进程生命周期。进程退出,Session 虽然被持久化到 JSONL 文件,但不会被自动恢复——下次 headless 运行是全新的 task、全新的 Session。每次运行都是独立的。你可以把每次 headless 调用想象成一次函数调用:有输入(task),有输出(response),调完就没了。
Web 模式下,Session 可以跨进程存活。用户关闭浏览器标签页、下次打开,ApiProxy 根据 SessionId 恢复 Session,重建 Agent。这就是为什么 Web 模式需要 crash recovery(第 24 章)而 Headless 模式不太需要——Headless 进程崩了就崩了,重跑一遍就行。但 Web 进程崩了,用户在浏览器里看到的多个对话必须能恢复。
这意味着 Web 模式的 Session store 必须是持久化的、可恢复的,而 Headless 模式的 Session store 可以是纯内存的(虽然实际上也会写 JSONL,但那更多是为了可观测性而不是恢复)。
你在实现 Session 相关逻辑时要注意这个区别。如果你加了一个需要”Session 重建”的功能(比如从 JSONL 恢复中间状态),它在 Headless 模式下可能永远不会被触发。你的测试覆盖策略必须考虑到这一点——不能只用 headless 跑测试就觉得 Session 恢复逻辑没问题。
场景三:多 Agent 协作
Headless 模式同时只有一个 root Agent。如果你的任务需要 sub-agent(比如 workflow),sub-agent 确实会被创建,但主进程的退出条件是”root agent 完成”,不是”所有 agent 完成”。你不需要管理 agent 池。sub-agent 是 root agent 的附属,root 完了它们也跟着 dispose。
Web 模式可以同时存在多个独立对话,每个对话一个 Agent。它们共享同一个 AgentRegistry store,但彼此独立。一个对话的 Agent 被 dispose 不影响其他对话。你需要考虑资源隔离——多个 Agent 竞争文件系统、竞争内存、竞争 LLM API quota。这在 Headless 模式下根本不是问题,因为同时只有一个活跃的对话。
另外,Web 模式下的 sub-agent 和”另一个对话窗口的 agent”是完全不同的概念。前者通过 lineage 关系绑定到 parent agent(第 29 章),后者是 ApiProxy 独立创建的、互不相关的顶层 agent。AgentRegistry.store 里两种都有,但只有通过 lineage 追踪才能区分”谁是谁的 sub-agent”。
场景四:InitiatorScope 的意义
在 Headless 单 agent 模式下,InitiatorScope 看起来没什么用——只有一个 agent,谁还需要追踪”谁发起的”?
但在 Web 多 agent 模式下,InitiatorScope 变得关键。当一个 tool call 触发了跨 agent 的操作(比如 parent agent 的 mailbox 接收了 sub-agent 的消息),你需要知道这条消息的发起者是哪个 agent,才能正确路由 response、正确记录 telemetry、正确判断权限。
即使在 Headless 模式下,当存在 sub-agent 时 InitiatorScope 也是有意义的——它让你知道当前的文件写入操作是 root agent 还是 sub-agent 发起的,这对权限审计(第 49 章)很重要。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -rn "getInitiator\|withInitiator" "$repo/packages/core/" | grep -v "node_modules" | head -8
场景五:测试时的 mock 策略
测试 Headless 相关逻辑时,你 mock HEADLESS_STARTUP_SERVICE 就行——传入一个 task 字符串,runner 自然会跑。不需要 HTTP server、不需要 JSON-RPC、不需要 WebSocket。单元测试可以在几毫秒内完成。
测试 Web 相关逻辑时,你需要 mock 整个 ApiProxy 的请求链——或者直接用 integration test 启动一个 web server 然后通过 HTTP 客户端调用。复杂度高一个量级。如果你搞混了两条路的测试策略,你会浪费大量时间搭建不必要的基础设施。
具体来说:如果你只是想测试”agent 收到 task 后能不能正确生成 system prompt”,用 headless mock 足够了。如果你想测试”用户在浏览器里发第二条消息时 agent 能不能正确追加到 session”,你必须用 web 的 integration test,因为”第二条消息”这个概念在 headless 模式下不存在。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
# 看 headless 的测试怎么 mock startup service
find "$repo/packages/bundle/headless" -name "*.test.*" -o -name "*.spec.*" | head -5
echo "==="
# 看 web-app 的测试怎么做 integration test
find "$repo/packages/bundle/web-app" -name "*.test.*" -o -name "*.spec.*" | head -5
带走的心智模型
这章最后,把两件最容易读错的点钉死:
第一,Agent 和 Session 在这里用的是同一张身份证。SessionId 就是 AgentId,store 的键也是 SessionId,enter 的时候还会断言两者相等。你在代码里看到 agent.id,基本可以直接当 session.id 读;反过来拿着 SessionId 找 agent,也就是 agents.store.get(sessionId),不需要再脑补一层映射。
第二,Headless 和 Web 不是“同一个系统的两种 UI”。它们的生命周期模型完全不同:一次性 vs 长驻,单 agent vs 多 agent,启动即创建 vs 按需创建,进程退出即结束 vs 显式 dispose 才结束。底下虽然都能走到 AgentRegistry.create,但上层控制流、并发、安全边界和恢复策略其实是两套。
你要是读着读着分不清自己在哪条路上,最省事的办法是看它 inject 了什么:headlessStartup 基本就是 headless;webStartup / ApiProxy / JSON-RPC 相关的就是 web;两个都没 inject、只摸 agents 的,多半是共享层。
这几章(第 6 章 inbox delivery、第 24 章 crash recovery、第 29 章 sub-agent lineage)会反复用到这里的 Headless vs Web 区分;如果前面没分清,后面你会把很多 bug 查错地方。
Agent 创建之后,第一件事就是消息投递:task 怎么进入 inbox,inbox 又怎样把它变成 Session event。Headless 的 task 字符串和 Web 的 JSON-RPC message,最后都会在这里碰面。