聊 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 挂载成一棵插件树。web、headless、sdk 和 acp 不是四套独立内核,而是同一套运行时的不同产品入口。
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 把这棵树组装成具体产品。官方提供 web、headless、sdk、sdk-minimal 和 acp 等启动 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-step、agent/request 和 tools/* 是实时扩展点,插件可以观察、改写或拒绝正在进行的工作。turn/start、step/start、user/message、assistant/message 和 tool/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 产品的通用底座。可在今天,“一切皆插件”的另一面仍然是“一切都要自己负责”。
所以更合理的姿势,是先读配置树和事件流,再放进隔离环境里试。暂时别把真实生产工作交给它。