DeepSeek Harness 到底在做什么?拆开 Agent 的运行时

2 阅读14分钟

聊 Agent,大家通常先聊模型。

但模型只负责“想”。一旦 Agent 真要进入代码库,它还得读文件、跑命令、保存会话、申请权限,并在中断后知道自己做到了哪一步。同一个模型接进不同产品,实际体验可以差很多,差异常常就在这些模型之外的工程里。

这部分工程通常就叫 Harness。

DeepSeek Harness 的野心,是把这层一直藏在 Coding Agent 内部的系统单独开源。模型、工具、会话、沙箱、存储、Agent Loop,甚至 UI,都可以作为插件重新组合。

官网把它概括成 “Everything is a plugin”。这句话很醒目,也很容易让人误以为它只是一个插件更多的 Agent 框架。真正值得顺着源码追下去的,其实是另一个问题:

当运行时的每一部分都能替换时,怎样保证执行过程仍然说得清?

DeepSeek Harness 给出的答案可以压缩成两句话:能力可以重组,历史只追加;工具可以换,副作用必须经过同一条管线。

这篇文章就沿着这两条线拆,不做功能大全。

先别把 Harness 理解成模型外壳

一次普通模型调用很简单:

输入消息 → 模型推理 → 输出消息。

可一个能修改代码库的 Agent,至少还欠着这些能力:

  • 决定模型本轮能看到哪些上下文;
  • 暴露工具,并校验模型生成的参数;
  • 在权限允许的范围内执行副作用;
  • 将工具结果送回模型,继续下一步推理;
  • 管理长会话、压缩、重试、取消和恢复;
  • 记录模型实际看到和执行过的内容。

这些东西都不在模型权重里,却决定了 Agent 能不能稳定工作。DeepSeek 官方给出的公式很直白:

Agent = Model + Harness

所以 DeepSeek Harness 不是新模型,也不只是另一个 Coding Agent。它想做的,是一套能被不同 Agent 产品复用的运行时。

先把 DSH 的整体架构摊开

理解 DSH,最容易走偏的地方是直接钻进 Agent Loop。实际上,它最外层先是一个应用装配系统,然后才是 Agent Runtime。

所有受支持的 Node 应用都从同一个 dsh CLI 启动。CLI 选择具名 Profile,Profile 再按顺序叠加 Bundle 和 Patch,最后交给 Cordis Loader 挂载成一棵插件树。webheadlesssdkacp 不是四套独立内核,而是同一套运行时的不同产品入口。

flowchart TB
  U[用户 / 外部应用]:::entry --> E[dsh CLI]:::entry
  E --> P[Profile]:::compose
  P --> B[Bundles + Patches]:::compose
  B --> C[Cordis 插件树]:::compose

  C --> A[应用入口<br/>Web / Headless / SDK / ACP]:::app
  A --> R[Agent Registry + Inbox]:::runtime
  R --> L[Agent Loop]:::runtime

  L --> Q[请求组装<br/>System Prompt + Tool Schemas]:::runtime
  Q --> M[LLM Adapter]:::runtime
  L --> T[Tool Registry]:::runtime
  T --> Q
  T --> X[执行世界<br/>FS / Shell / Sandbox / Approval]:::execution

  L --> S[Session 事件日志]:::state
  S --> D[Persistence<br/>JSONL / SQLite]:::state
  S --> O[UI / SDK / Transcript / Replay]:::observer

  classDef entry fill:#edf5ff,stroke:#3776a8,color:#1f2937
  classDef compose fill:#f3f0ff,stroke:#7765b5,color:#1f2937
  classDef app fill:#eef7f3,stroke:#3f7f67,color:#1f2937
  classDef runtime fill:#eef6ff,stroke:#2d6f9f,color:#1f2937
  classDef execution fill:#fff5e8,stroke:#a96a2f,color:#1f2937
  classDef state fill:#f7f2e8,stroke:#8b7444,color:#1f2937
  classDef observer fill:#f3f4f6,stroke:#6b7280,color:#1f2937

图 1:DSH 从统一启动器、插件装配到运行、执行和持久化的整体结构。

从上往下看,可以把它分成四层。

第一层是入口。 dsh web 面向浏览器,headless 运行一次性任务,sdk 通过 stdio 暴露 JSON-RPC,acp 面向 Agent Client Protocol。入口不同,但都通过具名 Profile 启动。

第二层是装配。 Profile 决定启用哪些 Bundle,Patch 决定替换哪些配置项。常用 Profile 共享 dsh-base,由它提供模型适配器、工具、持久化、沙箱、审批、设置、凭据和遥测;各应用 Bundle 只补上自己的入口能力。sdk-minimal 是例外,它使用一棵独立、显式的最小配置树。

第三层是运行。 Agent Registry 管理活跃 Agent,Inbox 接收用户消息、注入上下文和中途引导,Agent Loop 再把它们推进成一个个 Turn 和 Step。系统提示词、模型适配器和工具注册表都是可替换服务,不是写死在循环内部。

第四层是事实与执行。 Session 日志记录已经发生的事实,Persistence 负责把日志落盘;工具注册表则把所有真实副作用送进同一套策略、审批和执行管线。UI、SDK、回放和文本记录都从这些事件派生,而不是各自维护一份状态。

这张图里最重要的分界是:agent/* 事件负责活跃任务的实时控制,session/event 负责可持久化、可回放的事实。前者可以被插件拦截,后者必须在重启后仍然说得清。

“一切皆插件”,难的不是装,而是卸

DeepSeek Harness 建在 Cordis 之上。运行中的应用不是一个固定内核外挂几个工具,而是一棵由插件共同组成的能力树。

flowchart LR
  B1[Bundle 1]:::base --> B2[Bundle 2]:::base
  B2 --> P[Profile Patch]:::patch
  P --> H[Home Patch]:::patch
  H --> O[CLI --patch]:::patch
  O --> F[最终插件树]:::result

  classDef base fill:#eef6ff,stroke:#2d6f9f,color:#1f2937
  classDef patch fill:#fff5e8,stroke:#a96a2f,color:#1f2937
  classDef result fill:#eef7f3,stroke:#3f7f67,color:#1f2937

图 2:最终插件树由多个配置层顺序叠加,越靠后的层优先级越高。

插件通过 Context 找到服务,通过依赖声明决定何时激活,再通过类型化事件协作。看到这里,很容易把它归类成依赖注入加事件总线。Cordis 真正多走的一步,是把插件注册的服务、监听器和工具都当作可撤销 effect。

换句话说,插件不只要装得上,还得卸得干净。

Cordis 论文把这件事叫作“时空可组合性”:

  • 时间维度:组件移除后,其副作用可以被撤销;
  • 空间维度:依赖出现或消失时,组件可以响应式激活或停用。

这不是为了把架构说得更漂亮。Agent Runtime 迟早会替换模型适配器、权限策略、工具或 UI。旧插件如果卸载后还留下监听器和服务,系统不会立刻崩,却会开始出现最难排查的那类问题:配置看起来已经换了,行为却还带着上一套运行时的影子。

DeepSeek Harness 再用 Profile 和 Patch 把这棵树组装成具体产品。官方提供 webheadlesssdksdk-minimalacp 等启动 Profile;Bundle、Profile、本机配置和命令行 Patch 按顺序叠加,后应用的配置优先。

同一个 Agent Loop 因而可以搭配不同模型、工具、存储和入口。代价是,配置不再只是几个无害参数。一个 Patch 就足以替换工具、权限或持久化实现,它应该像代码一样接受评审。

这里还有一个容易忽略的细节:Patch 不是深度合并某个配置对象,而是按条目 id 定位后替换整个 config。后应用的层优先,因此命令行 --patch 可以覆盖 Home、Profile 和 Bundle 之前给出的配置。

使用前最值得执行的命令不是直接启动,而是:

dsh --profile web --dump-config

先看这台机器究竟会挂载什么,再决定要不要运行。比起先启动再猜,这个入口实在得多。

一条消息是怎样跑完的

整体结构看清后,再进入 Agent Loop 就容易多了。DSH 对执行边界有两个明确概念:

  • 一个 Turn 从领取输入开始,到系统不再欠任何工作时结束;
  • 一个 Step 包含一次模型请求,以及这次请求产生的工具调用。

一个 Turn 可以包含多个 Step。模型调用工具后,工具结果会再次进入请求上下文,驱动下一个 Step,直到模型自然停止或流程被拒绝、取消、失败。

flowchart LR
  I[Inbox]:::live --> T[Turn + pre-step]:::live
  T -->|拒绝| Z[turn/end]:::event
  T -->|进入| S[Step + 模型请求]:::runtime
  S --> C{工具调用}:::decision
  C -->|有| X[工具管线]:::runtime
  X --> N{还有工作}:::decision
  C -->|无| N
  N -->|继续| T
  N -->|结束| Z

  classDef live fill:#edf5ff,stroke:#3776a8,color:#1f2937
  classDef runtime fill:#eef7f3,stroke:#3f7f67,color:#1f2937
  classDef event fill:#f7f2e8,stroke:#8b7444,color:#1f2937
  classDef decision fill:#fff5e8,stroke:#a96a2f,color:#1f2937

图 3:一个 Turn 可以循环多个 Step,直到模型与工具都不再产生后续工作。

这里同时存在两类事件。

agent/pre-stepagent/requesttools/* 是实时扩展点,插件可以观察、改写或拒绝正在进行的工作。turn/startstep/startuser/messageassistant/messagetool/result 则会进入 Session,成为之后能够恢复和回放的持久事实。

这也解释了为什么 DSH 不把 Agent Loop 做成一个无法插手的黑盒:上下文压缩、请求路由、工具策略、错误恢复和中途引导,都需要在生命周期的明确位置接入。

运行时可以变,历史不能跟着变

插件越自由,第二个问题就越棘手:组件换过之后,谁来回答“当时到底发生了什么”?

DeepSeek Harness 没有分别维护消息历史、审计日志和恢复状态。它把 Session 做成一份由 SessionEvent 组成的只追加日志,模型消息历史只是这份日志的一种投影。

flowchart TB
  E[SessionEvent 只追加日志]:::source
  E --> M[deriveMessages<br/>模型可见历史]:::view
  E --> U[UI 流式展示与回放]:::view
  E --> R[Transcript / Resume / Fork]:::view
  E --> P[Persistence]:::store
  P --> J[JSONL]:::store
  P --> S[SQLite]:::store

  classDef source fill:#eef6ff,stroke:#2d6f9f,color:#1f2937
  classDef view fill:#eef7f3,stroke:#3f7f67,color:#1f2937
  classDef store fill:#f7f2e8,stroke:#8b7444,color:#1f2937

图 4:模型上下文、界面、恢复和存储都由同一份 Session 日志派生。

这里最值得注意的不是 tool/call,而是 request/header。它记录本次请求使用的模型配置、渲染后的系统提示词和工具 Schema。以后排查问题时,看到的不是按当前配置猜出来的 Prompt,而是模型当时收到的请求快照。

做过 Agent 调试的人通常都怕一种情况:日志里看着一切正常,模型收到的上下文却是另一回事。request/header 正是在堵这个缺口。

上下文压缩也不删除历史事件。压缩插件追加摘要,再通过 surface replacement 改变模型可见的投影:完整事件日志继续保留,模型可见历史则用摘要替换旧区间。

因此,恢复、Fork、搜索、回放和 Trajectory View 都能建立在同一条事件流上,不需要各自维护一份“差不多”的状态。

持久化本身也是一个可替换 seam。JSONL 后端为每个会话保存独立的只追加日志,SQLite 后端把相同的逻辑事件流存入共享数据库;调用方拿到的仍然是连续的 SessionEvent。进程在 Turn 中途崩溃时,恢复逻辑不会截断已经写入的事件,而会补上一条 turn/end { reason: interrupted },明确标记这次执行没有正常结束。

当然,这不是免费的:

  • 日志会持续增长;
  • 事件格式需要长期兼容;
  • 投影错误会让模型看到错误历史;
  • “记录完整”不等于“执行安全”。

现在的 Session 格式仍是 v0。官方不承诺预发布格式兼容,也没有旧格式迁移路径;读不懂的日志就拒绝加载。这比悄悄猜一个迁移结果可靠,但也提醒我们:它离“长期保存几十万条生产会话”还有一段距离。

权限不能散落在每一个工具里

Agent 最危险的瞬间,不是模型说错一句话,而是这句话变成了真实操作。

如果 Shell、文件编辑器和浏览器各自实现一套权限,新工具迟早会漏掉某个检查。DeepSeek Harness 选择把所有调用送进同一条工具管线:

flowchart LR
  C[模型调用]:::request --> V[参数校验]:::request
  V --> P[pre-policy<br/>+ 单调守卫]:::policy
  P --> X[execute]:::execute
  X --> O[post-policy<br/>+ 冻结结果]:::execute
  O --> R[写入 Session]:::result

  classDef request fill:#edf5ff,stroke:#3776a8,color:#1f2937
  classDef policy fill:#fff5e8,stroke:#a96a2f,color:#1f2937
  classDef execute fill:#eef7f3,stroke:#3f7f67,color:#1f2937
  classDef result fill:#f7f2e8,stroke:#8b7444,color:#1f2937

图 5:工具参数在执行前完成校验,结果在进入 Session 前完成后置处理和冻结。

这里有三个细节,比界面上弹出一个确认框更重要。

只有明确的一次性批准才能放行

策略可以允许、拒绝,也可以把决定交给用户。只有审批服务明确返回 allowed-once 才会继续;拒绝、取消、没有审批通道,甚至审批服务出错,都会停下来。

最终守卫只能收紧,不能被后续插件放宽

多个插件可以参与前置决策,但最后的守卫只有“保持”或“收紧”两个方向。一旦它拒绝,后面的插件不能再改成允许。否则,安全插件刚锁上的门,业务插件转头又能打开。

日志参数与实际参数必须一致

工具参数在进入策略前就完成校验并冻结,pre-execute 不能偷偷改写。如果 UI 和日志显示删除 ./tmp,真正执行的却是另一个路径,后面的审计再完整也没有意义。

统一管线的价值就在这里:审批、超时、结果裁剪、指标和保密策略只需要守住一个入口。

Code mode 快在哪?它没有绕过权限

普通 Function Calling 是一问一答:模型调用工具,拿到结果,再决定下一步。步骤一多,模型和工具之间就要来回很多次。

PTC mode,也就是官方界面中的 Code mode,采用另一种呈现方式:模型主要看到 run_code 和根据可用工具生成的 SDK,再写一段 TypeScript 程序编排调用。

下面是概念性伪代码:

const [branch, status] = await Promise.all([
  tools.bash({ command: 'git branch --show-current' }),
  tools.bash({ command: 'git status --short' }),
])

return { branch, status }

这样做省掉的是模型往返:中间数据可以留在程序里,互不依赖的调用也能并发执行。但 run_code 不是权限后门。每个 SDK 子调用仍然要回到工具注册表,经过参数校验、策略、守卫和结果处理,并留下可追踪事件。

所以 PTC 改变的是工具怎么交给模型、模型怎么组织调用,不是安全边界。

这也是整套架构最一致的地方:插件可以换,调用方式可以换,但副作用仍走同一条执行路径。

但别急着把它当成生产底座

灵活性不会消失,它只会转化成治理成本。

第三方 Bundle 不只是增加一个功能。它可以带入依赖和 Patch,直接改变运行时组合。装插件这件事,本质上已经接近供应链评审。

“沙箱”两个字也最容易让人放松警惕。默认 base Profile 的新会话使用 workspace-write,但它约束的是文件写入范围;读取和网络访问不受这套文件策略限制,进程可见性还取决于具体后端。

sdk-minimal 这个名字尤其容易误导。这里的 minimal 指能力组合少,不是权限小。它固定使用 danger-full-access,也不挂载审批服务。

官方安全声明没有绕弯子:项目仍是实验性的 Developer Preview,尚未经过安全审计,不能当作安全或生产就绪的软件。模型生成代码、错误配置、恶意输入和第三方插件,都可能造成文件损坏或数据泄露。

事件日志能告诉你发生过什么,却不能替你阻止事故。可追溯从来不等于安全。

如果现在要试,我会先做四件事

与其立刻交出真实仓库,不如先拿一个没有敏感信息的测试项目做四个实验。

1. 展开最终配置

--dump-config 确认模型、工具、权限、存储和 UI 分别由哪个插件提供。修改一个 Patch,再观察插件树如何变化。

2. 跑一条短任务并检查轨迹

选择没有敏感信息的测试仓库,让 Agent 完成一次读取、修改和命令执行。检查 Trajectory 中的 Prompt、工具参数和结果能否与实际操作对应。

3. 主动制造一次拒绝

让策略拒绝一个写操作,确认审批缺失时是否失败关闭、日志是否准确、后续插件能否绕过守卫。

4. 放进真正的隔离环境

使用临时容器、虚拟机或专用用户,限制网络和凭据,并保留可恢复备份。不要把 workspace-write 当成全部安全边界。

四步走完,基本就能判断它的组合能力是否值得那份治理成本。跑通一个 Demo 反而说明不了太多。

它适合谁,又不适合谁

如果你正在开发 Agent 产品,需要替换模型、工具、会话或执行策略,也确实在意恢复、回放和轨迹分析,DeepSeek Harness 值得认真读源码。前提是团队愿意维护自己的 Profile,也有能力隔离环境、评审插件。

反过来,如果需求只是找一个成熟的日常编码助手,或者马上要处理高价值凭据和不可信代码,它并不是合适选择。稳定 API、长期格式兼容和默认安全都还没有准备好。

我的结论:值得读源码,还不值得托付生产

DeepSeek Harness 最值得看的,不是工具数量,而是它认真划出了三条边界:能力由插件组合,模型看到的事实来自只追加日志,真实副作用统一经过工具管线。

这套设计把一个常被忽略的问题摆到了台面上:Agent 不是“模型加几个函数”,而是一套长期运行、会改变环境、也必须解释自身行为的软件系统。

如果插件 API、事件格式和安全治理逐渐稳定,它有机会成为搭建 Agent 产品的通用底座。可在今天,“一切皆插件”的另一面仍然是“一切都要自己负责”。

所以更合理的姿势,是先读配置树和事件流,再放进隔离环境里试。暂时别把真实生产工作交给它。

参考资料

  1. DeepSeek Harness 官方主页
  2. 官方仓库 README
  3. 架构文档
  4. Agent 轮次与步骤生命周期
  5. Session 事件模型
  6. 会话持久化
  7. 工具执行管线
  8. 沙箱边界
  9. CLI 行为参考
  10. 官方安全声明
  11. Cordis 论文