Loading

【大模型Wiki】20 · DeepSeek Harness 源码解读

DeepSeek Harness 源码解读

新增篇目。基于 DeepSeek 官方开源仓库与架构文档(dsh-v0.1.2-alpha.2,2026-09 时点)整理。
仓库:https://github.com/deepseek-ai/deepseek-harness | 介绍页:https://deepseek.com/harness
许可 MIT,主语言 TypeScript,npx @deepseek-ai/dsh web 即可体验。

一、项目定位

DeepSeek Harness(dsh) 是 DeepSeek 官方开源的 Agent Harness:一个"一切皆插件(Everything is a plugin)"的插件优先运行时。模型、工具、技能、会话、沙箱、存储、循环、调度、UI——每一项能力都是可替换、可重组的插件。

官方口号:Agent = Model + Harness。模型是灵魂,Harness 让 Agent 在真实环境中理解环境、使用工具、持续工作。

三个卖点:

  1. 长自主任务:会话可追溯、可续跑——重构一个模块、迁移代码库、跑研究循环都可以交给它,出错可回退重试
  2. 可审计:每一步、每次工具调用与输出全部留痕(合规、代码评审场景)
  3. 开放技术栈:用哪个模型、哪些工具、什么记忆、什么 UI 全由插件组合决定,不被厂商绑定

为什么这件事值得单独写一篇源码解读?因为 2025–2026 年"Agent 运行时"成了兵家必争之地:每家公司都在造自己的"让模型用工具、跑长任务"的外壳。dsh 的特别之处在于它把"可插拔、可追溯、可审计"做成了架构级不变量而非事后补丁——读它的源码,等于读一份"如何设计一个靠谱 Agent 运行时"的参考实现。下面所有章节都围绕"它为什么这么设计"展开。

二、总体架构

DeepSeek Harness 架构

┌───────────────────────────────────────────────────────┐
│                      dsh 运行时                        │
│                                                       │
│   Profile(web / headless / sdk / sdk-minimal / acp)  │
│        │  按序叠加组合包 bundle + patch 文件            │
│        ▼                                              │
│   ┌─────────────────────────────┐                     │
│   │        Cordis 内核           │  插件挂载/卸载/依赖  │
│   └─────────────────────────────┘                     │
│        由插件贡献的能力(core 包):                    │
│   ┌──────────┬────────────┬───────────┬────────────┐  │
│   │ session  │ system-prompt│  tools   │ agent-loop │  │
│   │ 追加日志  │  提示词组装   │ 工具注册表 │ 循环驱动    │  │
│   ├──────────┼────────────┼───────────┼────────────┤  │
│   │   llm    │   scope    │  webhook  │ UI/沙箱/存储│  │
│   │ 模型适配  │ 作用域注册  │  外部触发  │  …均为插件  │  │
│   └──────────┴────────────┴───────────┴────────────┘  │
└───────────────────────────────────────────────────────┘

理解这张图的关键,是把"运行时"拆成两层:

  • 内核层(Cordis):只负责"插件如何挂载、如何互相发现、如何卸载回滚"。它本身不知道 session 是什么、工具是什么——这些全由上层插件贡献。内核越小越稳,扩展越不会发生冲突。
  • 能力层(core 包等):一堆向内核贡献能力的插件。每个能力就是一个 ctx 键(如 ctx.sessions、ctx.tools)。换产品 = 换一组挂载的插件,不动内核。

这种设计把"产品差异"彻底下移到了"插件组合",内核保持稳定。这也是为什么 dsh 能用同一套内核支撑 web / headless / sdk / acp 四种形态。

三、Cordis:插件内核

dsh 不存在"需要打补丁的特权内核"——扩展方式就是把插件挂到其他插件旁边。

  • 插件向共享上下文贡献:服务(service)、类型化事件(event)、可逆的副作用(插件卸载时其注册自动撤销)
  • 服务与事件让插件之间协作,而无需互相 import

把"可逆的副作用"讲透:传统框架里,一个插件启动时往全局注册表塞东西,卸载时常常忘了清理,导致"卸载不干净"。Cordis 的做法是——插件在 apply(ctx) 里做的每件事都返回一个清理函数(dispose),内核在卸载时统一调用。于是"装上去"和"卸下来"天然对称。

// 一个最小 Cordis 插件(伪代码,带中文注释)
export const myToolPlugin: Plugin = (ctx) => {
  // 向共享上下文贡献一个服务
  ctx.provide('myService', { doThing: () => {/* ... */ } });

  // 监听某个事件域
  ctx.on('tools/pre-execute', (e, next) => {
    if (shouldBlock(e)) return;      // 拒绝:不调用 next,截断
    next();                          // 放行:交给下一个监听器
  });

  // 返回一个清理函数:卸载时自动撤销上面的注册
  return () => {
    ctx.remove('myService');
    ctx.off('tools/pre-execute');
  };
};

插件之间"不互相 import"是协作解耦的核心:插件 A 想用插件 B 的能力,不 import B,而是 ctx.get('bService')——通过内核这层中转。好处是 B 可被整体替换(换实现、换厂商)而 A 无感。

3.1、Profile 与组合包(bundle)

  • Profile:具名组装模板,随发行版交付的有 web(浏览器应用)、headless(一次性运行器)、sdk(JSON-RPC 服务)、sdk-minimal(显式配置的极简 SDK)、acp(自动化 ACP 服务器)
  • Bundle(组合包):Cordis 配置 + 挂载代码的分发格式;dsh-base 是共享第一层(模型适配器、工具、持久化、沙箱与审批策略、凭据、遥测),dsh-web-app 等在其上叠加
  • Patch:按 id 定位某个配置条目并整体替换,或插入新条目。层叠顺序:bundles(按 profile 顺序) → profile 的 cordis.patch.yml → home 级 patch → --patch overlay

"bundle 是分层蛋糕"这个比喻最贴切:dsh-base 是蛋糕胚(人人都要的能力),dsh-web-app 是上面那层奶油(web 专属)。层与层之间通过 patch 的 id 定位做"定点替换",而非整体覆盖——所以用户可以在不动 dsh-base 源码的前提下,把"某个工具的默认行为"改成自己的。

调试利器——打印本机最终生效的配置树:

dsh --profile web --dump-config

--dump-config 的价值怎么强调都不过分:当五层 patch 叠完,你看到的"最终配置"往往和任何单层源文件都不一样。--dump-config 把合并后的真相打印出来,让"千人千面"的运行时依然可调试、可审计——否则组合爆炸后没人说得清"某次行为到底是哪个 patch 造成的"。

3.2、核心包与 ctx 键

包 职责 ctx 键
core/session 仅追加的 SessionEvent 日志与内存存储 ctx.sessions
core/system-prompt 提示词片段与工具 schema 组装 ctx.systemPrompt
core/tools 作用域化工具注册表 + 带把关的执行流水线 ctx.tools
core/agent Agent 接口、活跃 agent 注册表 ctx.agents
core/agent-loop 默认循环驱动器 ctx.agentLoop
core/scope 按 agent 划分作用域的注册原语 库,无 ctx 键
llm/llm 消息与流式词汇表、适配器接缝 ctx.llm
webhook/webhook 已认证分发与 Workspace Session 创建 ctx.webhookRuntime

这张表是"读源码的地图"。想改提示词,找 ctx.systemPrompt;想加工具,找 ctx.tools;想换模型,找 ctx.llm。每个 ctx 键背后都是一个插件贡献的服务,改行为就是换/扩展这个插件,而不是去改一个中央大函数。这把"改动的影响面"锁死在单个插件内,是大型 Agent 项目能长期演进的前提。

3.3、插件挂载时序与依赖解析

内核怎么决定"先挂谁、后挂谁"?Cordis 用依赖声明拓扑排序:

  • 插件 A 若声明依赖 ctx.tools,内核保证 tools 插件在 A 之前 apply。
  • 同层无依赖的插件顺序不确定(不应依赖),这强制你"只用 ctx 取服务、不假设挂载顺序"。

这种时序约束带来一个工程纪律:任何插件启动时都不应假设其他服务的"初始化副作用"已发生,只能在被调用时通过 ctx.get 惰性取用。它杜绝了"初始化顺序 bug"——这类 bug 在中心化框架里极难复现。

四、事件系统:三类事件域

"选对事件域"是改动的第一个决定:

  1. 会话事件(持久):追加到日志并广播 session/event——事实必须在重载后仍存在时使用
  2. Agent 事件(agent/*,实时):携带活跃 Agent 的 inbox、步骤、状态、请求、验证、续跑——观察/拦截进行中的工作
  3. 能力事件(fs/*、tools/*、telemetry/*):向某个接缝附加策略与适配器

其中 agent/pre-step、agent/request、llm/stream、tools/* 是 waterfall(瀑布式)事件——监听器必须调用 next() 把控制权交给下一个监听器(类似中间件洋葱模型)。

把"三类事件域"的设计意图讲透,本质是一份持久性分级表:

  • 会写进日志、进程重启后还要在的 → 会话事件(session/event)。
  • 只在"这一次运行、这一个 agent 活着的时候"有用、重启即丢 → Agent 事件(agent/*)。
  • 跟"某个能力的策略"有关、可随插件加载卸载的 → 能力事件(fs/*、tools/*)。

为什么分这么清?因为持久性决定了"能不能丢、要不要广播、要不要落盘"。如果把"工具调用结果"误发到会话事件域,重启后它会变成"历史事实"被模型当真;如果"临时草稿"发到 Agent 事件域,又会因进程退出而丢失。选错域 = 架构bug。文档把它列为"第一个决定",正是因为它影响全局正确性。

4.1、waterfall 洋葱模型详解

waterfall 事件(agent/pre-step、tools/pre-execute 等)强制 next() 链式调用,形成洋葱模型:

请求进入
  │
  ▼ [监听器 1: 鉴权] ──next()──▶
        │                        ▼ [监听器 2: 改写/打点] ──next()──▶
        │                                                  ▼ [真正执行]
        │                                                  │
        │◀──────── 响应沿原路返回(后注册的先收尾)─────────┘
        ▼
   回到监听器 1 收尾

关键点:

  • 不调用 next() = 截断。这正是"审批/拦截"的实现方式:安全插件在 tools/pre-execute 里发现危险命令,直接 return 不 next(),工具就不会执行。
  • 后 on 的先收尾:洋葱模型的对称结构让"前置检查"和"后置处理"能在同一个监听器里成对写,代码局部性好。
  • 异步安全:next() 返回 Promise,下游出错上游能 catch,整条链可中断并向上抛错。

4.2、事件域选择决策树(工程速查)

改动需要"重启后还在"吗?
  ├─ 是 → 会话事件(session/event),并设计新 SessionEvent 类型
  └─ 否 → 需要在"进行中"观察/拦截 agent 吗?
            ├─ 是 → Agent 事件(agent/*)
            └─ 否 → 给某能力加策略/适配器 → 能力事件(fs/*, tools/*, telemetry/*)

五、Agent 循环:轮次与步骤

这是 dsh 的心脏,与 08 篇的通用 Agent 循环一一对应,但工程化程度高得多:

  • 步骤(step) = 一次模型请求 + 它调用的工具
  • 轮次(turn) = 零或多个步骤;领取首条输入前打开,不再欠工作后关闭
turn/start
  领取下一步输入 + 队列中的一条消息
  组装提示词片段 + 工具 schema
  → agent/pre-step          # 瀑布事件:改写或拒绝消息(决定模型看到什么)
     step/start
     消息写入日志(user/message)
     从日志投影出模型历史(deriveMessages)
     agent/request → llm/stream → assistant/chunk* → assistant/message
     tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
     step/end
     工具结果欠一次请求 / 或新输入到达 → 领取 → 下一个 step
  → agent/turn-stopping     # 串行事件:可终止轮次
turn/end

5.1、turn/step 生命周期时序(细化)

把上面的骨架展开成带状态机的时序,更能看清"零个步骤的轮次"是怎么来的:

turn/start
  │  打开一个轮次,状态 = RUNNING
  ▼
loop:
  input = inbox.take()                  # 领取一条输入(可能阻塞)
  if input == null and no pending tool: # 无事可做
       break → turn/end
  emit agent/pre-step(input)            # 瀑布:可被改写/拒绝
  if rejected:
       log("turn with no step")         # ← 零步骤轮次:仅留痕,不调模型
       continue loop
  step/start
   log(user/message)
   history = deriveMessages(log)        # 从日志投影模型可见历史
   emit agent/request(history)
     → llm/stream → assistant/chunk* → assistant/message(log)
   for each tool_call in assistant.message:
     emit tools/pre-execute  → tools/execute → tools/post-execute
     emit tool/result
     inbox.push(tool/result)            # 工具结果回到收件箱,欠一次请求
  step/end
  emit agent/turn-stopping              # 串行:可在此终止轮次
  goto loop
turn/end

注意三个容易忽略的细节:

  1. tool/result 回到 inbox:工具输出不是"塞进当前上下文"这么简单,而是作为一条新输入回到收件箱,走和用户输入完全相同的 take → pre-step → request 路径。这保证了"模型对工具结果的处理"也受同一套闸门约束(可被拦截、可被改写)。
  2. turn-stopping 是串行事件:它不强制 next(),而是"广播一个可终止轮次的机会"——比如超时控制器监听它,超时就标记 shouldStop,循环下一轮 take 前检查即退出。串行(非 waterfall)意味着"不会被某个监听器截断",更适合作出"是否结束"的全局判断。
  3. 轮次何时关:只有当 inbox 空且没有任何未决工具调用时,take() 返回 null,turn/end。所以"一个轮次"= "从用户一句话开始,到模型不再要工具、且没新输入闯入为止"的一整段自主工作。

5.2、inbox 收件箱与唤醒机制

  • inbox 收件箱:所有输入经同一 inbox 到达;有的消息立即唤醒循环,注入的上下文则留在 inbox 等待下次唤醒

inbox 是"把所有输入归一化"的关键抽象。用户打字、工具返回、webhook 触发、子 agent 回传——统统进 inbox。循环是单消费者,按顺序 take()。这带来两个收益:

  • 统一的背压与优先级:inbox 可以排队、去重、合并,避免"模型正忙时新输入直接插断上下文"的竞态。
  • 可注入上下文:agent.inject() 把一段上下文塞进 inbox,但标"非唤醒",它不会立刻打断当前步,而是等下一轮 take 时才被模型看到——这就是 3.2 速查表里"注入模型可见上下文"的语义:落点明确是"下次获准的请求",不会污染正在进行的 step。
// agent.inject 语义(伪代码)
function inject(ctx, sessionId, content) {
  const inbox = ctx.sessions.get(sessionId).inbox;
  inbox.push({ content, wake: false });   // 不唤醒:等下一轮 take 才进入
}

5.3、循环不变量:模型可见即已记录

  • 模型可见即已记录(模型可见即已记录):抵达模型请求的一切必须能从日志重建,这是运行时不变量(有断言保障)

这是 dsh 最硬的一条纪律,也是"可审计"的根。它要求:任何进入 agent/request 的 token,都必须先作为某个 SessionEvent 落进日志。内核会在 deriveMessages 处(或 agent/pre-step 出口)做断言:模型历史里的每条消息,都能在日志里找到对应事件;找不到就抛错。

为什么这么较真?因为一旦"模型看到了日志里没有的东西"(比如某个没记录的注入、某个被改写后遗留在内存的上下文),可追溯/可续跑/可分叉就全部破产——你无法从日志重建出模型当时到底看了什么,也就无法复现、无法审计、无法 fork。把这条做成断言而非约定,等于用崩溃强制所有人遵守。

六、会话日志:唯一的真相源

会话日志是模型所见上下文的唯一来源:

  • deriveMessages() 从日志投影出模型历史;原始 assistant/chunk 事件保证回放与 UI 保真
  • fork、resume、transcript、遥测、持久化全部派生自同一条事件流
  • 模型可见的新输入 = 必须扩展 SessionEventMap 增加新会话事件并从日志渲染
  • 投影接缝:ctx.sessionProjections 允许插件注册"增量折叠"单元,宿主通过 stateOf() 读取类型化状态——UI 与状态管理都以日志为底座

这就是 09 篇说的"traceable/resumable/fork"的具体实现:不是各处埋点,而是架构上只允许一条真相流。

6.1、append-only 日志与投影(projection)的关系

"append-only 日志"和"模型看到的历史"是两个东西,中间由 deriveMessages 投影连接:

SessionEvent 流(真相,只追加,不可改)
   │  deriveMessages()  投影
   ▼
模型历史 messages[](派生,可重算)
   │  stateOf()  折叠
   ▼
类型化状态(UI / 插件读取的"当前进度")

关键洞察:真相只写一次、永不修改;所有"视图"都是派生出来的。于是:

  • 回放(replay):从空状态重放整条事件流,就能 100% 重建任意时刻的上下文——这正是 resume 的实现。
  • 分叉(fork):从事件流第 N 个事件切一刀,前半段照拷、后半段另起炉灶——因为上下文全是派生的,fork 不需要复制"状态",只需复制"事件"。
  • 保真 UI:存原始 assistant/chunk(流式 token),UI 回放时连"打字机效果"都能原样还原。

6.2、会话状态管理的设计权衡

为什么选"单条 append-only 流"而非"维护一个可变状态对象"?这是 dsh 最核心的权衡,值得展开:

方案 可追溯 可续跑 可分叉 写放大 读成本
可变状态对象 差(覆盖即丢历史) 难(需额外快照) 难(需深拷贝) 低 低
append-only 事件流 优(天然全历史) 优(重放即可) 优(切流即可) 中(只追加) 中(需投影)

取舍结论:Agent 运行时最稀缺的是"正确性保证"(可追溯/可续跑/可分叉),而这些恰好是 append-only 流的免费赠品;代价只是"每次读要投影"——而这个代价可用 sessionProjections 的增量折叠摊掉(每来一条事件只更新受影响的那部分状态,不全量重算)。

// 增量折叠单元示例(伪代码):每条事件到达时只更新计数器
ctx.sessionProjections.register('toolCallCount', {
  init: () => ({ count: 0 }),
  fold: (state, event) => {
    if (event.type === 'tool/call') state.count++;
    return state;                       // 返回新状态(不可变更新)
  },
});
// 宿主读取
const s = ctx.sessions.stateOf(sessionId, 'toolCallCount');

这就是"投影接缝"的价值:把"从日志算状态"这件贵事,拆成"每个插件只管自己那一小块的增量折叠",既保住单一真相源,又不让投影成为性能瓶颈。

6.3、fork / resume 的工程实现

  • fork 活跃会话:ctx.sessions.fork(source, boundary?)

fork 的语义是"以某个事件为界,复制前半段事件流到一个新 session"。boundary 缺省则复制到当前最新事件。它之所以便宜,正是因为底层是事件流——fork 不复制任何"运行态",只复制"事件序列",新 session 在第一次 deriveMessages 时自行投影出自己的历史。

对比中心化框架:很多 Agent 系统想做"从中间重试",得手动快照内存、深拷贝上下文、处理引用——dsh 把这件事降维成"切一条只追加的流",复杂度从一个量级掉到复制数组。

七、能力接缝(Seam):换一个提供方,换掉半个产品

seam = 可替换能力,三角色:Service Definition(接口) + Service Provider(实现) + Consumer(消费者,通常是面向模型的工具)。

官方给出的例子最能说明问题:文件系统与进程提供方共享同一个执行世界——把它们指向远程沙箱,Bash、PTY、LSP 就全部搬了过去,无需逐个改造。subagent 提供方同理:从"新建一个子 Agent"到"把整个轮次委派给另一个产品",都在同一接口之后。

7.1、Seam 三角色代码骨架

// 1) Service Definition:接口(只定义契约,不实现)
interface FsService {
  read(path: string): Promise<string>;
  write(path: string, content: string): Promise<void>;
}

// 2) Service Provider:实现(可替换)
const localFsProvider: ServiceProvider<FsService> = {
  id: 'fs/local',
  provide: (ctx) => ({
    read: (p) => ctx.fs_local.read(p),
    write: (p, c) => ctx.fs_local.write(p, c),
  }),
};

const remoteSandboxFsProvider: ServiceProvider<FsService> = {
  id: 'fs/sandbox',                 // 换实现:指向远程沙箱
  provide: (ctx) => ({
    read: (p) => ctx.sandbox.read(p),
    write: (p, c) => ctx.sandbox.write(p, c),
  }),
};

// 3) Consumer:面向模型的工具,只依赖接口
ctx.tools.register({
  name: 'read_file',
  // 它不关心 fs 是 local 还是 sandbox——取当前激活的 provider
  run: async (args) => ctx.get<FsService>('fs').read(args.path),
});

为什么"接口-实现-消费者"三分离这么重要?因为替换 provider(实现)时,consumer(工具)一行都不用改。文档里那句"换一个提供方,换掉半个产品"的含义是:当你把 fs 和 process 两个 seam 的 provider 一起换成远程沙箱版,所有依赖这两个 seam 的工具(Bash、PTY、LSP、文件编辑)自动整体迁移——这正是"能力成组迁移"的工程本质,不是一个个改工具,而是换底层世界。

7.2、新行为的归属(速查表 + 解读)

想做的事 机制
接入新模型 在 ctx.llm 注册适配器
加面向模型的能力 在 ctx.tools 注册工具,schema 自动进提示词
限制进程启动 ctx.sandbox 后端;消费方启动前包装 argv
文件系统访问/策略 注册 ctx.fs 提供方或监听 fs/*
拦截请求/工具/轮次 agent/*、tools/* 事件
注入模型可见上下文 agent.inject()(落到下次获准的请求)
fork 活跃会话 ctx.sessions.fork(source, boundary?)

这张表的读法:先判断"这是能力、事件还是上下文",再去对应 ctx 键/事件域挂号。它把"我想加个功能"映射到"我该碰运行时哪一块",避免新手去改不该改的核心循环。

八、四种运行时模式

模式 工具集 用途
Standard 标准模式 文件编辑、shell、文件/网页检索、skills、计划、目标、子 Agent、工作流 完整 Coding Agent
Code / PTC 模式 标准能力 + Code Mode SDK:模型写 TypeScript 程序一次编排多步工具调用 减少"每步一次模型调用"的开销(Programmatic Tool Calling 思路)
Minimal 极简模式 仅持久 bash + str_replace_editor 两个工具 给模型做基准评测的最小环境
Creator 创造模式 标准能力 + 运行时检查、内存中试验 Cordis 插件、preset 创作 开发者组装自定义 Agent preset

四种模式就是四条不同的 profile/bundle 组合——"用配置而非改源码来定义产品"的活教材。

补充读解:

  • Code / PTC 模式的巧思:普通 Agent 每调一个工具就走一轮"模型请求→执行→回结果",长任务里模型调用次数爆炸。PTC 让模型写一段 TS 程序把多个工具调用编排进去,一次跑完——把"N 次模型往返"压成"1 次编程 + 1 次执行",显著降延迟与成本。这是 test-time compute 思想在 Agent 侧的对称物:不是让模型想更久,而是让它"一次想清楚怎么批量做事"。
  • Minimal 模式是"控制变量"的标杆:评测一个模型真实推理/编码水平时,必须剥掉所有花哨工具,只留"能改文件 + 能跑命令"的最小集合,否则分数混进了 Harness 的能力。它证明了 dsh 既能当产品,也能当评测底座。
  • 四种模式零源码分支:它们只是四个不同的 profile 挂载了不同 bundle。同一份内核、同一套插件,靠配置切成四个产品——这是"配置即产品"的最佳注脚。

九、值得借鉴的五个设计决策

  1. 插件即一切 + 可逆副作用:扩展系统不用改核心,卸载即回滚——把"微内核"做到了工具级
  2. Append-only 会话日志为单一真相源:可追溯/可续跑/可分叉不是功能,是架构不变量的必然结果
  3. 瀑布式事件做策略层:pre-step / pre-execute 等关卡天然支持审批、改写、审计的洋葱模型
  4. 能力接缝三角色:接口-实现-消费者分开设计,替换提供方即替换能力,且能力成组迁移(fs+shell 一起搬进沙箱)
  5. Profile/Bundle/Patch 的配置分层:发行版交付模板 + 用户 patch 覆盖,--dump-config 全透明——运营一个可被千人千面重组的运行时而不失控

展开这五条的"工程思考":

  1. 可逆副作用:它把"扩展"从"危险操作"变成"可逆操作"。插件作者只需在 apply 返回清理函数,内核保证对称卸载。结果:任何人都能安全试验新能力,不怕把运行时搞脏——这直接降低了"内部创新能力"的协作成本。
  2. 单一真相源:把"可审计"从 KPI 变成数学必然。因为所有视图都从一条不可变流派生,审计员只需读日志;不会出现"日志说 A、内存说 B"的不一致。代价是投影开销,已用增量折叠补齐(6.2)。
  3. 瀑布策略层:把"审批/改写/打点"这些横切关注点,从"散落在循环各处"收拢到固定关卡。新增一个安全策略 = 加一个 tools/pre-execute 监听器,循环代码零改。
  4. Seam 三角色:把"厂商绑定"从架构里连根拔起。换模型、换沙箱、换存储都是换 provider,consumer 无感——这正是"开放技术栈"卖点的技术落地。
  5. 配置分层:把"可定制"与"可维护"这对矛盾和解了。发行版管稳定层,用户 patch 管差异层,--dump-config 让合并结果可观测。没有它,千人千面的运行时会变成无人能调试的黑洞。

对于想造自己的 Agent 运行时的人,这份源码(连同其 docs/architecture.zh.md、agent-lifecycle.zh.md、tool-execution-pipeline.zh.md 等中文文档)是目前最好的公开参考之一。

十、模块交互时序总览(新增)

把前面分散的时序拼成一个"一次完整请求"的跨模块视图,便于建立全局心智模型:

用户/Webhook
   │ 消息
   ▼
[webhook / inbox] ──▶ [core/session] 追加 session/event(持久)
   │
   ▼ 唤醒 agent-loop
[core/agent-loop]
   │  ① 取 inbox 输入
   ▼
[core/system-prompt] 组装 提示词片段 + 工具 schema
   │
   ▼ agent/pre-step(waterfall:可改写/拒绝)
[core/llm] 取激活的模型适配器
   │  agent/request → llm/stream
   ▼
[模型] 输出 assistant/chunk* → 落日志 assistant/message
   │
   ▼ 若含 tool_call
[core/tools] tools/pre-execute(waterfall:审批)
   │  tools/execute → 实际能力(fs/process/sandbox seam)
   ▼  tools/post-execute → tool/result 回到 inbox
   │
   └──▶ 回到 agent-loop 取 input(tool/result 作为新输入)
        │
        ▼ 无欠工作 → agent/turn-stopping → turn/end
[core/session] 整轮事件已落盘,可 resume / fork

读这张图的要领:所有跨模块数据都先过 core/session 落日志,再被投影出去。换句话说,模块之间不是"直接传对象",而是"写事件 → 别人从日志读"。这正是"单一真相源"在时序层面的体现——它用一点点"间接层"换来了全局的可追溯。

十一、给开发者的落地建议(新增)

如果你要基于 dsh 或借鉴它的思想造自己的运行时:

  • 先定事件域,再写代码:任何新能力第一问"它该进哪个事件域"。选错域的代价远高于多写几行。
  • 把"模型可见性"做成断言:在 agent/request 前校验"每条消息都能在日志找到源",早期就把可追溯钉死,后期省无数 debug。
  • 策略用 waterfall,判断用串行事件:拦截/改写/打点走 next() 链;"是否结束/是否超时"走串行广播,别混用。
  • 能力抽象成 seam:凡是"将来可能换厂商"的(模型、存储、沙箱、检索),从第一天就定义 Service Definition,别让 consumer 直接 import 实现。
  • 用 Minimal 模式做基线评测:上任何新工具前,先在 Minimal 下跑一遍基准,确认分数变化来自模型而非 Harness。
  • 善用 --dump-config 做回归:改了 patch 后 dump 一次,diff 历史,防止"层叠顺序悄悄改变了行为"。

十二、参考

posted @ 2026-09-19 05:43  Stellan7  阅读(32)  评论(0)    收藏  举报