DeepSeek Harness 教材交互式 · 可自学 · 可面试
0%
交互式技术课程 · 基于源码精读

DeepSeek Harness:把 Agent 跑起来只是开始

一句话核心:DeepSeek Harness 是一个以 Cordis 插件内核 为中心的 Agent 运行时——"一切皆插件",用可重放的事件日志(Event Log)作为模型上下文的事实来源,让模型、工具、存储、沙箱、UI 都能被替换而不必改动核心循环。
本教材基于 master 分支、commit 47f943859bef60e4160492346772ded9b24f765a(2026-08-13,dsh@0.1.0-rc.5,Developer Preview)逐行精读官方文档、子系统文档与源码结构而成。所有结论以该 commit 为准。
47f9438分析 commit
0.1.0-rc.5项目版本(Dev Preview)
~23章节 + 6 个 Lab + 15+ 面试题
约 4–6 小时阅读 + 动手预计
静态精读 ✔ 未在本环境实跑(无 API Key / 网络受限) 结论随 commit 变化风险:中

推荐学习路线

① 建立直觉

1-3

为什么需要 Harness → 先跑起来 → 一张图理解全景

② 理解内核

4-12

Cordis 插件 · Profile/插件树 · Turn Flow · Event Log · System Prompt · Tools · Seams · Scope

③ 工程与实战

13-23

Web/Headless · 构建系统 · 错误恢复 · 默会知识 · 取舍 · 6 个 Lab · 面试

前置知识清单:基本编程常识(变量、函数、异步、事件);了解 LLM / Chat 是什么;知道 TypeScript 类型与 npm 包;对"插件""配置""依赖注入"有模糊概念即可。不需要你已经写过 Agent。
导读

1. 为什么需要 Agent Harness(而不是写一个 ReAct while 循环)

一个"能调用工具的大模型"几分钟就能写出来;一个"能在生产里长期可靠运行、可恢复、可审计、可替换组件"的 Agent,是另一回事。Harness(马具/挽具)这个比喻非常精准:它不替马决定往哪走,而是把马、车、缰绳、刹车、载货、仪表盘可靠地组装在一起。

第一层:高中生能懂
第二层:工程师要知道

想象你让一个非常聪明但"失忆"的助手帮你完成一件大事:查资料、改文件、跑命令。你每次都要把"到目前为止发生了什么"重新讲给他听,他才能继续。如果中途被打断、电脑重启,你还得把进度完整复述一遍。Harness 就是把"复述进度、记录动作、检查权限、决定下一步、保存现场"这些杂活儿系统地管起来的那套装置。

没有它,你就得每次手写这些杂活;写一次两次还行,要长期稳定、多人协作、出问题能查,就乱了。

ReAct 风格的核心循环就是 while(有任务){ 模型推理 → 解析工具调用 → 执行 → 把结果塞回上下文 → 重复 }。Demo 阶段这够用。但当需求变成:

  • 上下文必须可重建(换机器/续跑/复现 Bug);
  • 能力(模型、文件系统、沙箱、UI)必须可替换
  • 工具调用要审批/沙箱/超时/取消
  • 多入口(CLI、Web、Headless、IDE);
  • 可观测、可审计、可测试

——纯 while 循环会变成一坨耦合的代码。Harness 把这些关注点拆成清晰的接缝。

1.1 "Demo 很简单、生产化很难"到底难在哪

Demo 时代只需关心生产化必须额外解决
把消息数组传给模型消息数组从哪里来、如何重放、如何裁剪、如何不污染
直接 await tool()权限、审批、沙箱、策略、超时、取消、部分结果、错误恢复
一块代码跑通组件可替换(换模型/换沙箱/换 UI 不改动核心)
控制台打印结构化事件、遥测、可审计、可回放
进程不崩就行中断恢复(resume)、fork、并发取消、副作用回滚
工程推断: DeepSeek Harness 把"一切皆插件"作为顶层约束,本质就是用插件化把"生产化难点"变成"可在卸载时反向撤销的、隔离的扩展点",从而让核心循环保持精简、可测试、不被具体能力污染。

1.2 它在 Agent 技术栈里处于哪一层

应用 / 产品 Web UI · IDE · CLI · ACP 服务器 — 你写的那一层 DeepSeek Harness — Agent 运行时(编排层) 循环 · 会话 · 工具 · 能力缝 · 配置 · 事件日志(一切皆插件) 能力适配器(实现可替换) LLM(DeepSeek 等)· 文件系统 · 子进程 · 沙箱 · 子 Agent · 持久化 · 凭据 基础设施 操作系统 / 容器 / 远程服务 / 外部 API

Harness 不直接实现模型能力,也不实现 UI;它位于"产品"与"能力适配器"之间,是运行时与编排层。它和 LangGraph / AutoGen / OpenAI Agents SDK 属于同一抽象层(见第 18 章对比),但把"插件内核 + 事件日志"作为一等公民。

本教材结论的时效性:项目处于 Developer Preview。文档明确警示接口尚不稳定、随时可能破坏性变更,请勿用于生产。所有实现细节以 47f9438 为准;若你读到时 commit 已变,请回到本教材"源码阅读地图"按相同入口重新核对。

本章要点

  • Harness ≠ 模型,也 ≠ 一个固定的 Agent 程序;它是一个"让 Agent 能可靠长期运行"的运行时。
  • Demo 的 while 循环缺的是:可重建上下文、可替换能力、工具治理、可观测/可审计、中断恢复。
  • DeepSeek Harness 的回答是:一切皆插件 + 事件日志即真相 + 能力缝(Capability Seam)
先跑起来

2. 先跑起来:建立第一印象(Lab 0)

先不求懂,先把东西跑起来、看到"实际挂载了哪些插件""最终配置长什么样"。这一步能把所有抽象名词变成你屏幕上真实的东西。

本环境未实跑 以下步骤来自 docs/development.md 静态确认

2.1 安装与运行(来自 development.md)

# 前置:Node 22.19+ / 24+,pnpm 11.7.0(Corepack 启用)
corepack enable
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install          # 同时装 worktree-local lefthook 钩子与翻译配对 merge driver
pnpm run typecheck    # 成功退出即环境就绪
pnpm run build        # tsc(host)→tsdown(host)→tsc(client)→tsdown(client)→web

# 运行 Web(浏览器入口,默认 http://127.0.0.1:3080)
pnpm dsh --profile web
# 或免安装直接试(README)
npx @deepseek-ai/dsh web

# Headless:一行命令让 Agent 直接干活(需 DEEPSEEK_API_KEY)
DEEPSEEK_API_KEY=sk-... pnpm dsh --profile headless "summarize this workspace"

2.2 看"实际挂载了什么"比读文档更重要

文档强调:组合(composition)、派生标志(flag)、配置转储(dump)都由同一套 applyEntryPatches 算法生成,所以 --dump-config 的结果 等于 真正启动时要挂载的插件树,不会漂移。这是理解"实际启动结果"的最佳入口。

# 导出最终配置(含所有 bundle / patch 层合并后),离线、可读、可加载
pnpm dsh --dump-config --profile web
# 只看默认模板、忽略用户补丁层
pnpm dsh --dump-config --profile web --default-only
为什么 --dump-config 很有价值:当你怀疑"我配置的插件好像没生效",先 dump 一下,确认它在最终插件树里。很多"配置看似生效、实际没挂载"的问题,靠这个命令一眼定位(详见第 17 章默会知识)。

2.3 Lab 0 目标与完成标准

  • 目标:从源码启动;用 --dump-config 看到真实插件树;理解 Web 与 Headless 是同一内核的不同"profile(组合)"。
  • 涉及文件:apps/cli/src/bin.ts(入口分发)、packages/boot/app-boot(profile/bundle/patch)、docs/development.md
  • 预期结果:Web 在 127.0.0.1:3080 打开;dump 输出一份 YAML,列出所有 entry 及其 bundle/patch 来源层。
  • 验证:对比 --profile web--profile headless 的 dump,能说出两者差在哪几类能力(UI vs 无 UI)。
  • 常见错误:没设 DEEPSEEK_API_KEY 就在 Web 里发请求 → 真实 API e2e/调用 self-skip;pnpm install 后未跑构建就 dts 调试。
  • 思考题:为什么"dump 出来的配置"必须和"真正启动挂载的"完全一致?如果不一致会发生什么?
  • 完成标准:能独立解释 dsh --profile web 从敲下回车到打开网页,中间经过了哪些"组合层"。
本环境实测说明:由于运行环境无网络 clone 权限、无 DEEPSEEK_API_KEY,本教材中所有"运行/构建/测试"相关内容均为源码与文档静态确认,未在本机实跑。涉及"已验证"的结论将在文中以 ✔ 已验证 标注(仅 commit SHA、版本号、文档内容是通过 GitHub API/raw 实际抓取确认的);纯推断处标注 工程推断
全景

3. 一张图理解 DeepSeek Harness

先建立一张"系统全景图"。记住四个角色:配置树(谁被挂进来)核心运行时(循环/会话/工具/提示)能力缝(模型/文件/沙箱…)外部世界。下面这张图里的每个框,后面都会单独成章。

配置树 · Profiles / Bundles / Patches cordis.yml + dsh.profile + cordis.patch.yml — 决定「挂哪些插件、按什么顺序」 Host 运行时 · Cordis 根 Context — 一切皆插件 Agent Loopctx.agent / 驱动 Inboxagent.inbox · claim Session Event Logappend-only · deriveMessages Tool Registryctx.tools.register System Promptctx.systemPrompt.section Scope / Contextctx.scope(agentCtx) 能力缝 Capability Seams(可替换适配) LLM FS Subprocess Sandbox Subagent Persistence Credentials Client / UI(状态消费者 · 非真相源) · Web (React) · Headless · ACP(stdio) · IDE 通过事件订阅渲染 Agent 状态 ctx.remote — Host-for-Client 投影 仅「观看」会话事件,不下命令改真相 用户 只看 / 输入 外部世界 DeepSeek API(ctx.llm)· 操作系统/文件系统 · 容器/远程沙箱 · 子 Agent 进程 · 持久化存储/凭据库 能力缝把「内部接口」映射到「外部实现」 订阅 渲染
图例:实线箭头=组合/调用;绿色=能力缝(可替换适配);紫色=Client/UI(状态消费者);虚线框=运行时 / 外部世界边界。
第一层
第二层

把 Harness 想成一家"智能工厂"的中央调度室:墙上贴着排班表(配置树,决定今天哪些班组上班),中间是传送带与记录员(Agent Loop + Session Log),旁边一排可插拔的机器(能力缝:模型机、文件机、沙箱机),最外面是参观走廊(UI,只能看,不能改流水线)。

对应上图:配置树决定把哪些插件(班组)挂进根 Context;Agent Loop 驱动 turn/step 并在 Session Log 写事实;Tool RegistrySystem Prompt 组装给模型的输入;能力缝把内部接口接到外部实现(DeepSeek、OS、沙箱);Client/UI 只订阅事件渲染,不是状态真相。

关键点:UI 在虚线框外、用绿色/紫色区分——它永远不是"真相来源"(第 13 章详述)。

读完本章你能回答

  • 配置树、核心运行时、能力缝、外部世界各负责什么?
  • 为什么 UI 被画在"真相边界"之外?
  • 能力缝为什么被画成"可替换"的(绿色虚线)?
内核

4. Cordis 与 "Everything is a Plugin"

DeepSeek Harness 的底座是 Cordis(一个事件驱动、插件化的运行时内核)。理解四个词就够了:Plugin(插件)Service(服务)Event(事件)Effect(副作用)。它们都挂在一个共享的 Context 上。

4.1 四问框架

① 它是什么?

Cordis 是一个"插件容器":你往里挂一堆 plugin,每个 plugin 是一个 (ctx)=>void 函数,在里面注册 service、监听事件、声明副作用。容器启动时把插件组合成一棵"插件树"。

② 为什么存在?

为了让"增加一个能力"="挂一个插件",而不是改核心代码。且插件卸载时能反向撤销它声明的所有 service/事件/副作用,避免能力污染与资源泄漏。

③ 源码里怎么实现?

根 Context 由 boot() 创建(packages/boot/app-boot),通过 cordis:include 递归挂载 entry 树;assertEntriesLoaded/Activated 校验每个 entry 都真正挂载激活。

④ 为什么这样设计?

相比"一个庞大的 Agent 类",插件化让每个关注点独立、可测、可替换;"没有必须不断修改的 privileged core"是项目反复强调的纪律。

4.2 四个核心概念

概念作用在 Harness 中的例子
Plugin一个 (ctx)=>void,在 Context 上声明能力;可带 plugin={name, ...} 元数据ctx.agentctx.toolsctx.session 本身也是插件
Service挂在 Context 上的具名能力(ctx.foo),有生命周期ctx.llmctx.fsctx.toolsctx.systemPrompt
Event解耦的扩展点;监听器可拦截、转换、短路turn/starttools/pre-executesystem-prompt/assemble
Effect副作用(定时器、监听器、watcher),卸载时自动回滚配置热更新 watcher、消息节流、资源句柄

4.3 事件的四种分发模式(尤其 Waterfall)

文档特别强调 Cordis 事件有 4 种 dispatch 模式,理解它们对读源码至关重要:

模式语义监听器是否需要 next()用途
emit触发即忘,不等结果纯通知(如 turn/end
parallel并发触发多个监听互不依赖的副作用
serial按注册顺序依次触发有序副作用
waterfall上一个结果传给下一个,可 next() 短路/改写是(必须调用)System Prompt 组装、工具 schema 注入、结果改写
关键不变量:waterfall 事件里,监听器必须调用 next()(或显式返回以短路)。如果不调用,事件链会卡住,后续监听器永远收不到——这是作者重点防守的边界条件。System Prompt 的逐段拼装正是靠 waterfall 把各插件的 prompt.section 累积成最终提示。

4.4 "没有 privileged core" 与可卸载

项目反复强调:核心功能本身也是插件。好处是——当你移除一个能力,它注册的 service、事件监听、effect 全部被反向撤销(unwind),系统回到"没装过它"的状态。

默会知识(先剧透):"一切皆插件"既是一种能力,也是一种风险。能力来自"任何东西都能挂进来改行为";风险来自"插件之间形成隐式耦合"或"卸载顺序导致 effect 回滚不完整"。如何判断该不该新增插件,见第 17 章。

4.5 最小插件示例

// 一个"在每次 turn 开始时打印"的插件(基于 Cordis 惯例)
export const logTurnStart = (ctx) => {
  ctx.on('turn/start', (turn) => {
    console.log(`[turn] 开始: ${turn.id}`);
  });
};
// 通过 package.json 的 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
// 或在 cordis.yml 的 include 列表里挂上这个插件即可生效。
证据:插件结构来自 Cordis 惯例与 docs/cordis-primer.md;事件名 turn/start 来自 docs/subsystems/core.md 的 turn flow;组合与挂载校验来自 packages/boot/app-bootboot/assertEntriesLoaded/assertEntriesActivated

本章要点

  • Cordis = Plugin + Service + Event + Effect,全部挂在共享 Context 上。
  • waterfall 事件里的监听器必须调用 next(),否则事件链卡死。
  • "核心也是插件"使系统可增删、可测试、无特权核心;代价是插件治理成本。
配置组合

5. Profiles、Bundles、Patches 与插件树

"替换一项能力不该改动整个系统"——这是配置层的设计目标。DeepSeek Harness 用三层概念把"挂什么插件、以什么顺序、如何覆盖默认"做成了架构能力而非配置技巧。

Profile(配置档)

$DSH_HOME/profiles/<name>/ 下的目录:含 package.json(外部插件依赖)+ dsh.profile(有序 bundles 列表)+ cordis.patch.yml(用户补丁)。web/headless 是内建模板。

Bundle(捆绑包)

一个 npm 包,其 manifest 声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }loadProfiledsh.profile.bundles 里的名字解析成对应的 bundle 包。

Patch(补丁层)

YAML 数组:id 定向替换整段 config(不深合并)、insert 增加 entry、!!js 在挂载时插值。定位不到 id 的 patch 只警告。

5.1 层级关系与"配置层应用顺序"

Bundle 层(最底) 来自 dsh.profile.bundles,按序叠加 per-profile patch profiles/<name>/cordis.patch.yml home-level patch(优先级最高) ~/.dsh/cordis.patch.yml composeEntries → 最终插件树 = boot 实际挂载 = --dump-config 输出
顺序:bundle 层在最底,per-profile patch 叠加其上,home-level patch 最高。最终树由 composeEntries 用 include 自己的 applyEntryPatches 生成。

5.2 插件树长什么样

组合的本质是:从一个空 entry 列表出发,逐层 apply patch,最终得到一棵"插件树"。下面是一个 Web profile 的示意树:

└─ root include (cordis:include)
  ├─ bundle: dsh-app-web
  │   ├─ plugin: ctx.agent (Agent Loop 驱动)
  │   ├─ plugin: ctx.session (Event Log / 持久化)
  │   ├─ plugin: ctx.tools (Tool Registry + 执行管线)
  │   ├─ plugin: ctx.systemPrompt (System Prompt 组装)
  │   ├─ plugin: ctx.llm (LLM 能力缝 + DeepSeek 适配器)
  │   ├─ plugin: ctx.fs / ctx.subprocess / ctx.sandbox
  │   └─ plugin: web client (React UI,订阅事件渲染)
  ├─ bundle: dsh-tool-fs (提供文件类工具)
  └─ user patch (cordis.patch.yml:覆盖某插件 config / insert 自定义工具)
Headless profile 的树与 Web 几乎相同,区别只在"没有 web client 插件、多了一个把首条参数当用户输入的入口"。这正是"共享内核 + 不同组合"的直观体现。

5.3 一个"默认 → 用户覆盖"的具体例子

假设默认 bundle 里有一个工具插件 entry id 为 fs-tools,其 config 限制只能读 /workspace

# 默认 bundle 的 cordis.patch.yml(节选)
- id: fs-tools
  config:
    root: /workspace
    writable: false

用户在 ~/.dsh/cordis.patch.yml 想允许写入并改根目录。因为补丁是整段替换而非深合并,必须把要保留的字段也写上:

# ~/.dsh/cordis.patch.yml(home 层,优先级最高)
- id: fs-tools
  config:
    root: /home/me/project   # 覆盖 root
    writable: true           # 开启写入(root 字段也必须重述,否则会被丢掉)
# 想新增一个自己的工具:
- insert:
  - id: my-time-tool
    plugin: my-time-tool      # 从 node_modules 解析
已知限制(来自源码注释):id 定向补丁不深合并,会替换整段 config。所以覆盖一个字段时,要把想保留的字段一起重述。这是"配置覆盖是架构能力"的代价——明确、可预测,但需要你写全。

5.4 为什么 --dump-config 这么重要

renderConfigDump 用与 boot() 完全相同的 applyEntryPatches 算法离线合成配置并渲染 YAML,且把共享同一来源文件+补丁层的连续行加上 # == 注释标明来源。结论:dump 出来的就是你真正会挂载的树,三者(composition / flag / dump)不会漂移。

本章要点

  • Profile = 一组有序 bundle + 用户 patch;Bundle = 声明了 patch 的 npm 包;Patch = id 定向整段替换 / insert / !!js
  • 分层顺序:bundle < per-profile < home;最终树由 composeEntries 生成。
  • Web 与 Headless 是同一内核的不同 profile 组合,差异仅在 UI 入口。
  • --dump-config 是排查"插件没挂载"的利器,因为它 == 真实启动。
核心流程

6. 一次请求如何完成:Turn Flow(时序)

以"用户在 Web 里发一句话"为线索,追踪一次完整执行链。先记住一句话:Turn 是一次"用户发起的工作单元",Step 是其中一次"模型请求 + 其工具调用"。一个 Turn 可以有多个 Step。

6.1 时序图(含持久化事实标注)

UI / 用户
① 输入进入系统:agent.inbox.append 把用户消息交给 Agent
Inbox
② 接收输入:加入 agent.inbox 队列(运行时扩展点
Agent Loop
③ Turn 开始:claim → turn/start (持久化事实)
Agent Loop
④ Step 开始:pre-step 钩子 → 发出 step/start (持久化事实)
System Prompt
⑤ 组装:system-prompt/assemble waterfall 事件,各插件 ctx.systemPrompt.section 累加;工具 schema 经 ctx.tools.schemas 注入 (运行时派生,非持久化)
Session Log
⑥ 历史消息:deriveMessages() 把 append-only 事件日志投影成模型可见消息数组
LLM 能力缝
⑦ 模型请求:ctx.llm.call(config),DeepSeek 适配器发起请求
LLM 能力缝
⑧ 流式输出:StreamChunk 流式回传,UI 实时渲染(assistant 消息边生成边记录)
LLM 能力缝
⑨ 模型产出工具调用:返回 ToolExecutionInput(可能多个)
Tool 管线
⑩ 执行前拦截:tools/pre-execute → 权限/审批/沙箱/策略检查(可阻塞)
Tool 执行
⑪ 执行:tools/execute,运行工具函数
Tool 管线
⑫ 执行后处理:tools/post-executefinalizeContent(脱敏/裁剪)
Session Log
⑬ 结果写入:发出 tools/result,工具结果作为 tool/result 事件入日志 (持久化事实)
Agent Loop
⑭ 判断是否下一 Step:若 assistant 还有未执行的 tool call → 回到 ④;否则进入 turn-stopping
Agent Loop
⑮ Turn 停止与结束:agent/turn-stopping → turn/end (持久化事实),等待下一次输入
紫色=持久化事实(进入 Session Log,可重放);灰色=运行时扩展点/派生(不进日志,靠重放重建)。

6.2 Turn 与 Step 的区别(必考)

TurnStep
粒度一次用户发起的工作单元一次模型请求 + 它产生的工具调用
数量关系1 个 Turn 可含多个 StepStep 内模型可一次发起多个 tool call
对应事件turn/startturn/endstep/startstep/end
生命周期贯穿"用户意图被处理完"单轮"思考+行动"
为什么一个 Turn 可以有多个 Step?因为模型一次回复可能调用工具,工具结果要喂回去让模型继续推理(ReAct 的多轮)。每"模型请求→工具结果"算一个 Step;直到模型不再调用工具,Turn 才结束。

6.3 几个容易混淆的概念

Waterfall 事件

一种把"上一步结果传给下一步、可改写可短路"的事件。System Prompt 组装用它:每个插件贡献一段,最终拼成完整提示。监听器必须 next()

消息注入 / 等待 / 唤醒 / 继续

注入=往上下文加内容;等待=循环挂起等新输入;唤醒=新输入到达 claim 后继续;继续=下一个 Step 或 Turn。它们由 Inbox 与 Agent Loop 协同。

第一次输入被拒仍记录 Turn?

工程推断:若输入在被 claim 前就因校验/策略失败,系统可能仍记一个 Turner/step 作为审计事实(为何被拒)。具体以 core.md 测试契约为准——这是"一切模型可见皆被记录"原则延伸到"拒绝也值得记录"。

错误 / 取消 / 恢复

错误→step 失败事件→可能重试或结束;取消→中断正在进行的 step/tool;恢复→从 Session Log 重放续跑(第 15 章)。

6.4 逐段通俗解释

把上面的时序当成"一个助手接活"的过程:你(UI)把任务放进收件箱(Inbox)→ 助手开一张工单(Turn start)→ 助手先读公司手册并整理手头资料(System Prompt + 历史消息)→ 打电话问专家(LLM 请求)→ 专家边说边传(流式)→ 专家说"去查下文件"(Tool Call)→ 助手先请示主管能否查(pre-execute 审批)→ 查到后脱敏再记录(post-execute)→ 把结果写进工单本(Session Log)→ 若专家还要继续问,就再开一页(下一个 Step)→ 直到专家说"搞定了",工单关闭(Turn end)。

本章要点

  • Turn ≠ Step:Turn 是用户工作单元,Step 是单次"模型请求+工具调用"。
  • 持久化事实(turn/start、step/start、tool-result、turn/end)进日志;System Prompt 组装、流式渲染是运行时派生。
  • waterfall 事件监听器必须 next();工具管线在 pre/post 插入治理点。
内核

7. Agent Loop 的源码实现(packages/core/agent-loop)

第 6 章讲了"发生了什么",本章讲"代码里怎么驱动"。Agent Loop 的驱动 API 由 agent-loop 包提供(packages/core/agent-loop),通过 Cordis 插件挂载到 Context 上,驱动 turn/step 循环。

7.1 四问框架

① 是什么?

一个驱动 turn/step 循环的插件,通过 agent-loop 包挂载。它从 Inbox 取输入、开 Turn、跑 Step、处理工具调用,直到结束或等待下一次输入。

② 为什么存在?

把所有"循环/生命周期/取消/恢复"逻辑收口到一处,且它本身也是插件——可替换、可测,不污染具体能力。

③ 源码怎么实现?

Agent Loop 从 agent.inbox claim() 取输入;turn/startstep/start 事件标记边界;agent.cancel(cause) 中断;返回 AgentHandle(含 dispose())。

④ 为什么这样设计?

循环逻辑收口到一处,且它本身也是插件——可替换、可测,不污染具体能力。

7.2 Inbox 与 claim

// 来自 docs/subsystems/core.md 的语义(Inbox 挂在 agent 上)
interface Inbox {
  append(msg: ClaimableInput): void      // 入队
  claim(): ClaimableInput | undefined   // 取走一条
  // 还有 prepend / replace / remove / clear / splice
}
// Agent 接口暴露 readonly inbox: Inbox
// 输入先 append 入 inbox,再被 claim() 取走
核心不变量:输入先入 agent.inbox(经 append),再被 claim() 取走。Turn 只在 claim 到输入后才开始。这样"输入"与"执行"解耦,使取消/续跑可围绕 inbox 与日志重建。

7.3 驱动循环(伪代码骨架)

// 伪代码骨架(概念性,非精确源码)
async function drive(agentCtx) {
  while (true) {
    const input = agentCtx.inbox.claim();      // ② 取输入
    if (!input) { await waitForInput(); continue; } // 等待/唤醒
    // ③ turn/start 事件入日志
    do {
      // ④ step/start 事件入日志;agent/pre-step waterfall 触发
      const msgs = agentCtx.session.deriveMessages(); // ⑥ 历史
      const prompt = await agentCtx.systemPrompt.assemble(); // ⑤
      const out = await agentCtx.llm.call({ messages: [...msgs], prompt }); // ⑦⑧⑨
      if (out.hasToolCalls) {
        for (const call of out.toolCalls) {
          const res = await runToolPipeline(agentCtx, call); // ⑩-⑬ tools/* 事件
          // ⑬ tool/result 事件入日志
        }
        // → 回到 do 循环,跑下一个 step
      } else { break; }            // 模型不再调用工具 → 结束
    } while (true);
    // ⑮ agent/turn-stopping → turn/end 事件入日志
  }
}

这个骨架把第 6 章的 15 步一一对应起来了。AgentHandle 上的 dispose() 会停止循环、注销 agent、unwind 其 scoped world;agent.cancel(cause) 在当前 step / tool 执行处中断;agent/status 事件反映 idle/running。

7.4 取消、错误与恢复路径

  • 取消:agent.cancel(cause) 中断正在进行的 step/tool;已写入日志的部分保留,未完成的 step 不记录(或记录中断事件)。
  • 错误:工具/模型异常转为 step 失败;错误作为 tool/result 事件的 error 字段入日志,模型可据此调整。
  • 恢复:因为一切皆事件日志,重放日志即可在断点续跑——详见第 15 章。

本章要点

  • Agent Loop 从 agent.inbox claim 输入;turn/startstep/start 事件标记边界。
  • 循环 = claim → turn/start → (step/start → llm → 工具 → 回写) 直到无 tool call → turn/end。
  • 取消/错误/恢复都围绕"日志 + inbox"重建,而非保存某个 messages 数组快照。
状态真相

8. Session Event Log:模型上下文的事实来源

这是整个项目最反直觉也最重要的设计。Session Log 是一份 append-only 的事件日志,它是模型上下文的唯一事实来源。你看到的 messages 数组,只是对这份日志的一个"投影"(projection)。

8.1 为什么是日志,而不是"保存最终 messages 数组"

第一层
第二层

像记日记:每天发生什么就往本子上写一行,不许涂改。以后你想"还原某天发生了什么",照着日记重读就行。如果你只保存"最终的总结",细节就丢了,别人也无法核对你有没有篡改。

如果只存最终的 messages 数组:① 无法精确回放(流式、工具中间态丢了);② 无法 fork(分叉实验);③ 无法审计(谁在何时注入了什么);④ 无法从断点恢复(恢复需要"事件序列"而非"终态")。Event Sourcing 用日志重建一切派生物。

8.2 "Model-visible means logged"

项目有一条铁律:任何进入模型视野的内容,都必须能从日志重建。System Prompt、工具 schema、注入的上下文——只要模型"看得到",就必须是日志的派生结果,而不是某次运行临时塞进去的内存值。否则换一台机器重放,模型看到的东西就对不上了。

边界:System Prompt 的组装过程(waterfall 拼接)是运行时派生,不进日志;但"最终送给模型的提示内容"必须能从日志+插件状态重建。区别在"过程"与"事实"——事实(模型实际看到的)要可重放,过程可以重算。

8.3 事件类型(节选自 docs/subsystems/session.md)

事件类型含义是否持久化事实
turn/start · turn/endTurn 边界✔ 持久化
step/start · step/endStep 边界✔ 持久化
user/message用户输入✔ 持久化
assistant/chunk · assistant/message流式分段 · 最终消息✔ 持久化
tool/call模型发起的工具调用✔ 持久化
tool/result工具执行结果(含 error 字段表示失败)✔ 持久化
todo/writeTodo 列表写入✔ 持久化
request/header · request/context请求头/上下文✔ 持久化
steering/message · session/end-seed引导消息 · 种子结束标记✔ 持久化
插件可通过 declaration merging 扩展(如 compaction/*hook/*)。完整列表见 docs/subsystems/session.md。

8.4 一个事件日志示例

[{ "type": "turn/start", "turn": 1 }
, { "type": "user/message", "turn": 1, "text": "读取 src/app.ts 并总结" }
, { "type": "step/start", "turn": 1, "step": 1 }
, { "type": "assistant/chunk", "turn": 1, "step": 1
  , "text": "我先" } // 流式分段
, { "type": "assistant/chunk", "turn": 1, "step": 1
  , "text": "读文件" }
, { "type": "assistant/message", "turn": 1, "step": 1
  , "text": "我先读文件" }
, { "type": "tool/call", "turn": 1, "step": 1, "id": "c1", "name": "read-file", "args": {"path":"src/app.ts"} }
, { "type": "tool/result", "turn": 1, "step": 1, "id": "c1"
  , "content": [{"type":"text", "text":"export const App = () => ..."}] }
, { "type": "step/end", "turn": 1, "step": 1 }
, { "type": "turn/end", "turn": 1 }]

8.5 从日志派生出的能力

Replay 重放

把日志重新喂给驱动,精确复现一次运行(调试/测试)。

Resume 续跑

从日志末尾继续,中断后无需从头再来。

Fork 分叉

复制某点之前的日志,尝试不同后续。

Transcript 转录

把日志渲染成可读对话/记录。

Telemetry 遥测

统计 token、工具调用、耗时等。

Persistence 持久化

日志即存储,落盘/同步即可恢复。

默会知识:这份设计有真实成本——日志要落盘、要管理体积、要做裁剪/压缩(context 太大)。作者选择承担这些复杂度,是因为"可重放/可审计/可恢复"对生产 Agent 是刚需,而不是锦上添花。

8.6 Event Sourcing vs 普通状态存储

保存最终状态(DB 表)Event Log(本项目)
回放做不到(中间态丢失)天然支持
审计需额外日志表日志本身就是审计
恢复/分叉困难复制前缀即可
复杂度较高(投影、体积、裁剪)

本章要点

  • Session Log = append-only 事实来源;messages 是 deriveMessages() 的投影。
  • "模型可见即记录"——保证跨机器/断点重放一致。
  • Replay/Resume/Fork/Transcript/Telemetry/Persistence 全部从同一份日志派生。
  • 代价:落盘、体积、裁剪——这是必要复杂度,不是过度设计。
上下文

9. System Prompt 与上下文组装

System Prompt 不是一段写死的字符串,而是一组可注册、可排序、可作用域化的"提示段(prompt section)",在每次模型请求前通过 waterfall 事件动态拼装。

9.1 四问框架

① 是什么?

ctx.systemPrompt.section(section) 注册一段提示;PromptSection.order 字段控制顺序;scope 通过 AssembleContext.scope 让某段只对特定 Agent 生效。

② 为什么存在?

让插件(而非核心)贡献"模型该知道的背景",且不同 Agent 可有不同系统提示,互不污染。

③ 源码怎么实现?

每次请求前触发 system-prompt/assemble waterfall:各插件段被累积,工具 schema 经 ctx.tools.schemas(scope) 注入。

④ 为什么这样设计?

"动态拼装 + 作用域"比"写死 prompt"更适合插件化:加能力=加一段 prompt,而不是改核心字符串。

9.2 静态 vs 动态上下文

类型例子来源
静态 System Prompt"你是乐于助人的助手"配置/插件固定段
动态上下文当前仓库结构、用户偏好、注入的备忘插件在 derive 时实时计算
工具 Schema每个工具的 JSON Schemactx.tools.schemas(scope)

9.3 哪些进日志、哪些只是派生

  • 进日志(事实):用户消息(user/message)、assistant 输出(assistant/message + assistant/chunk)、tool/calltool/result——这些是模型真正"看到并回应"的内容。
  • 只派生(过程):System Prompt 的拼接动作、waterfall 调用顺序、某段是否启用——这些靠重放插件状态重算,不必存。
如何避免上下文重复/污染/不可重放:① 同一信息只由一个 section 提供,避免多插件重复注入;② 作用域化(AssembleContext.scope)让提示只对该 Agent 可见;③ 所有"模型可见文本"必须可由"日志 + 当前插件集"重建,否则重放时上下文漂移。

9.4 最小"模型上下文插件"示例(Lab 3 预览)

// 给某个 agent 注入一段动态背景
// section() 接收 PromptSection,含 name / order / scope / content
ctx.systemPrompt.section({
  name: 'project-rules',
  order: 100,
  scope: agentScopeKey,
  content: `当前项目规则:使用 TypeScript strict 模式;提交信息用 Conventional Commits。`
});

本章要点

  • Prompt = 可注册/排序/作用域化的 section,靠 waterfall 拼装。
  • 工具 schema 从 ctx.toolsschemas(scope) 注入。
  • "事实"进日志,"拼接过程"只派生;作用域化避免污染与不可重放。
工具

10. Tool Registry 与安全执行管线

工具是"模型能调用的函数"。但生产里的工具不能"模型一说就执行"——中间要插权限、审批、沙箱、策略。DeepSeek Harness 把这条链做成 tools/pre-execute → tools/execute → tools/post-execute → finalizeContent → tools/result 管线,每一段都是可监听的事件。

10.1 工具如何注册(最小自定义工具,Lab 2 核心)

// 一个"当前时间"工具:注册、Schema、执行、结果
ctx.tools.register({
  name: 'current-time',
  description: '返回当前 ISO 时间',
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  execute: async (toolCtx, args) => {
    return { content: [{ type: 'text', text: new Date().toISOString() }] };
  },
});

逐行解释:register(definition) 把工具挂进注册表(返回 disposer);inputSchema 是 JSON Schema,会被 ctx.tools.schemas(scope) 投影注入 System Prompt 让模型知道怎么调用;execute 返回 {content:[...]}(与 tool/result 事件格式一致)。模型看到 schema → 发起 tool/call 事件 → 管线接管。

10.2 执行管线(含事件)

模型发起tool/call 事件 pre-executetools/pre-execute · 审批/沙箱 executetools/execute post-executetools/post-execute · 脱敏 写回日志tools/result 可阻塞 / 可改写参数(permission · approval · sandbox) finalizeContent 可裁剪 / 脱敏结果 管线:pre-execute(allow/deny/ask)→ guards → execute → post-execute(accept/block)→ finalizeContent → result Agent Loop 在 pre-execute 之后 await tools/result 才继续下一个 step

10.3 四问框架

① 是什么?

Tool Runtime:ctx.tools.register 注册工具;ctx.tools.schemas(scope) 投影该 scope 可见的 schema。

② 为什么存在?

把"工具治理"(权限/审批/沙箱/超时/取消)与"Agent 循环"解耦,使循环不必知道每个工具的危险程度。

③ 源码怎么实现?

管线由 tools/pre-execute → guards → tools/executetools/post-executefinalizeContenttools/result 事件驱动。

④ 为什么这样设计?

若工具系统直接耦合循环,每加一个安全策略都要改循环。做成事件管线后,策略以插件形式挂在 pre/post 上。

10.4 失败 / 超时 / 取消 / 部分结果

  • 失败:execute 抛错 → 仍经 tools/post-executefinalizeContenttools/result,错误作为 tool/result 事件的 error 字段入日志,模型可据此调整。
  • 超时/取消:在 execute 阶段可被中断;已产生的中间态由 effect 回滚。
  • 部分结果:post-execute 阶段的 finalizeContent 可裁剪过大输出,只把"安全且必要"的部分喂回模型。
为什么工具系统不应直接耦合 Agent Loop:循环只负责"取输入→开 turn→跑 step→等结果";工具如何被审批/沙箱/脱敏,是横切关注点。解耦后,同一循环能服务"无审批的本地工具"和"强审批的远程沙箱工具"而无需改动。

本章要点

  • ctx.tools.register(definition) 注册;schema 经 ctx.tools.schemas(scope) 进提示。
  • 管线:tools/pre-execute(allow/deny/ask)→ tools/executetools/post-executefinalizeContenttools/result
  • scope 通过 schemas(scope) / get(name, scope) 参数传递;治理与循环解耦。
可替换能力

11. Capability Seams:让能力可替换的"接缝"

如果一个能力只能"写死调用某个 SDK",那它就无法替换、无法测试、无法远程化。DeepSeek Harness 用"能力缝(Capability Seam)"解决这个问题:把能力的定义、提供、消费三者拆开。

11.1 三种角色(缺一不可)

Service Definition

定义能力接口与元数据ctx.xxx.define(spec)。只说"能做什么",不说"怎么做"。

Service Provider

提供具体实现ctx.xxx.provide(impl)。可本地、可远程、可 mock。

Consumer

使用能力:ctx.xxx.use(...)ctx.xxx.call(...)。只依赖接口。

为什么三者共同才算完整 seam:只有接口(没人实现)→ 跑不起来;只有实现(没接口)→ 无法替换;只有工具(没服务层)→ 能力无法被其他插件复用/远程化。Definition + Provider + Consumer 三段齐备,能力才"可替换、可测、可组合"。

11.2 能力缝示意图

Service Definition ctx.xxx.define(spec) Provider(实现) ctx.xxx.provide(impl) Consumer(使用) ctx.xxx.use / call 声明接口 被使用 只依赖接口,不依赖实现

11.3 哪些能力是 seam

能力缝定义常见 Provider
LLMctx.llmDeepSeek 适配器、其他模型后端
FileSystemctx.fs本地 fs、远程沙箱 fs
Subprocessctx.subprocess本地 shell、容器 shell
Sandboxctx.sandboxLandlock、容器
Subagentctx.agent 子 agent同进程子 agent、远程 agent
Persistencectx.persistence本地文件、db、云端

11.4 推演:本地文件系统 → 远程沙箱

假设某 Agent 通过 ctx.fs.read(path) 读文件。要换成"远程沙箱里的文件系统":

  • Definition 不变(还是 read/write 接口);
  • 只换 Provider:从"本地 fs 实现"换成"把调用转发到远程沙箱 RPC 的实现";
  • Consumer(所有调用 ctx.fs.read 的插件)一行都不用改。

这就是 seam 的价值:替换环境能力时,模型能力和业务代码保持不变。

11.5 与常见架构模式的对应

能力缝 ≈ 依赖注入(DI) + 适配器模式 + 端口与适配器(Hexagonal):Definition 是端口(Port),Provider 是适配器(Adapter),Consumer 在内部通过端口调用。项目的特色是把它提升到"运行时一等公民"并以插件方式组合。

本章要点

  • 完整 seam = Definition + Provider + Consumer,三者缺一不可。
  • LLM/FS/Subprocess/Sandbox/Subagent/Persistence 都是 seam。
  • 换 Provider 即可把本地能力变远程,Consumer 不变——环境能力与模型能力解耦。
隔离

12. Scope、生命周期与副作用回滚

当一个系统里可能同时跑多个 Agent、或某个能力只应对"某一个 Agent"生效时,你需要"作用域(Scope)"。否则会出现能力污染、资源泄漏、互相干扰。

12.1 全局 Context vs Agent Context

  • Global Context(根 Context):整个应用共享,跨 Agent。适合全局服务(如 LLM 适配器、持久化)。
  • Agent Context(作用域 Context):每个 Agent 一份,由 ctx.scope(agentCtx) 派生。适合"只对这个 Agent 生效"的工具、提示、用户偏好。

12.2 作用域化注册原语

ctx.tools.schemas(scope)        // 投影该 scope 可见的工具 schema
ctx.systemPrompt.section({...})  // 注册提示段,可含 scope 字段
ctx.systemPrompt.context({...})   // 注册动态上下文,可含 scope
ctx.systemPrompt.assemble({scope}) // 组装该 scope 的完整提示
为什么某些能力只能对一个 Agent 生效?例如"只给代码审查 Agent 暴露写文件的工具,给问答 Agent 只给只读工具"——作用域化注册让同一内核跑出不同权限的 Agent,而不会互相看见对方专属的工具/提示。

12.3 插件加载/卸载与副作用回滚

每个插件在 Context 上声明的 service、事件监听、effect(定时器、watcher、句柄)在卸载时自动反向撤销(unwind)。这就是第 4 章"没有 privileged core"的生命周期保障。

如果没有 Scope 和可逆 Effect 会怎样:
  • 能力污染:A 插件注册的事件监听在卸载后仍在触发,干扰 B。
  • 资源泄漏:未清理的定时器/watcher 累积,内存与句柄耗尽。
  • 互相干扰:多 Agent 场景下,一个 Agent 的工具被另一个 Agent 误用。

12.4 多 Agent / 子 Agent 隔离

子 Agent(subagent)通过 scope 获得独立上下文:它的工具、提示、会话与父 Agent 隔离。父 Agent 调用子 Agent 时,子 Agent 在自己的 scope 内跑完整 turn/step,结果回传父 Agent——父子互不污染状态。

默会知识:Scope 不是"性能优化",而是"正确性保障"。很多生产 Agent 的诡异 Bug(工具被错误 Agent 调用、事件被重复触发)根因就是 scope 没管好或 effect 没回滚干净。

本章要点

  • 根 Context 全局共享;ctx.scope(agentCtx) 派生 per-agent 上下文。
  • 工具/提示/用户/会话都可作用域化,避免跨 Agent 污染。
  • 插件卸载时 effect 自动 unwind,防止资源泄漏与能力残留。
运行形态

13. Web、Headless 与 UI 状态同步

DeepSeek Harness 不是"一个程序",而是一组共享同一内核、由不同 profile 组合出不同形态的运行入口。

13.1 三种运行形态

形态入口差异来源
Webpnpm dsh --profile web(默认 127.0.0.1:3080多一个 React Client 插件(订阅事件渲染 UI)
Headlesspnpm dsh --profile headless "任务"无 UI;首参当成用户输入;跑完即退出
ACPpnpm run demo:acp(JSON-RPC stdio)把 Agent 暴露为 ACP 服务器(JSON-RPC over stdio),供外部 IDE 驱动

它们共享:Agent Loop、Session Log、Tool Registry、能力缝。差异只来自"挂了哪些插件"——也就是 profile 的 bundle 组合。

13.2 UI 如何消费状态(且不是真相源)

Agent 运行时(真相) Session Log + Agent Loop Host-for-Client Remote ctx.remote / agentCtx.remote Client / React UI 订阅事件 → 渲染 用户 只看 / 输入 投影 订阅 渲染

Client 通过 Typert 生成的 Host-for-Client Remote 投影ctx.remote 及作用域 agentCtx.remote)调用 Host 方法、订阅 Session 事件来渲染。UI 是观察者,不是状态拥有者

为什么 UI 不能成为状态真相来源?如果 UI 自己存了一份"当前对话",一旦刷新/断线/多端,就会和真实 Session Log 不一致,且无法重放/审计。真相只在日志里;UI 只是日志的一个视图。

13.3 如何增加另一种运行入口

新增一个入口 = 新增一个 profile(bundle 组合) + 一个把你想要的"前端形态"作为插件挂上。内核、循环、日志都不用动。这正是"替换一项能力不应修改整个系统"的体现。

本章要点

  • Web / Headless / ACP 共享内核,差异仅由 profile 组合产生。
  • UI 通过 Remote 投影订阅事件渲染,是观察者而非真相源。
  • 新增入口 = 新 profile,不碰核心循环。
工程化

14. Host/Client、Monorepo 与构建系统

这一章先讲"问题",再讲"配置"。核心问题:同一套 Cordis 插件既要跑在 Node(Host),又要跑在浏览器(Client),但两侧的 Context 接口声明会冲突。

14.1 为什么分 Host 与 Client 两个聚合

Host 与 Client 两侧都会在相同 key 下 declaration-merge Cordis 的 Context 接口,但服务不同(Node 侧有 fs/subprocess,浏览器侧有 DOM/React)。若放进同一个 ts.Program,声明合并会冲突报错。因此仓库用两个独立的 TypeScript aggregate:

配置角色是否形成 program
tsconfig.host.jsonHost 包、examples、tests、scripts、website、api/remotes(Host)
tsconfig.client.jsonpackages/client/*、其测试、apps/web、api/remotes(Client)
tsconfig.base.json共享 compilerOptions 与 paths(解析 facade)
tsconfig.base.client.json浏览器编译器设置(jsx、DOM libs)
关键纪律:tsconfig.base.json 永不增加 include/files;② 构建全仓库 program 的脚本必须显式 seed hostclient,绝不用根解决方案(会撞 Context 合并);③ 新包只注册到一个聚合;④ api/remotes 是唯一拆分 Host/Client tsconfig 的包。

14.2 构建顺序本身就是架构约束

tsc -b tsconfig.host.json      # 1. Host 类型检查/产物
tsdown --env.DSH_BUILD_FACE host   # 2. Host 打包(含 Typert)
tsc -b tsconfig.client.json    # 3. Client 类型(引用 Host 产物)
tsdown --env.DSH_BUILD_FACE client # 4. Client 打包
pnpm run build:web             # 5. Web 构建

为什么是这个顺序?因为 Typert 只在 Host tsdown 运行,它分析 Host 类型,生成 Host 反射产物与 Host-for-Client Remote 投影。Client tsdown 不启动 Typert,而是消费 Host 阶段生成的 /remote 声明。所以 Host 必须先于 Client。顺序不是随意的,它体现了"Client 依赖 Host 的远程契约"。

14.3 Typert 与 Remote Contract

  • 业务服务用 @Remote / @RemoteScope 声明"Host 可调、Client 可订阅"的方法。
  • Host 构建生成 Host-for-Client 类型与运行时贡献;Client 的 api-remotes 组合加载到 ctx.remote 与作用域 agentCtx.remote
  • 生成的 Remote 声明是例外:typecheck/lint/doc-typecheck 先生成它们。

14.4 Monorepo 布局

packages/*/*      # 包层(被 tsdown 显式 glob 纳入构建)
vendor/*          #  vendored: @deepseek-ai/cosmokit, schemastery 等
apps/*            # 产品装配;apps/cli 拥有 dsh bin
native/*         # 原生构建(如 Landlock launcher)
examples/         # 可运行 demo:union of cordis.yml plugins
website/ python/sdk-runtime/
哪些结构是特例、不要盲目复制:api/remotes 同时拥有 Host 与 Client tsconfig——因为它要既在 Host 图产出、又在 Client 侧被浏览器包引用。普通包只注册一个聚合,有 Node 入口又有浏览器入口不是拆分的理由。

本章要点

  • Host/Client 分离是因为 Cordis Context 声明合并在同一 program 会冲突。
  • 构建顺序 Host→Client 由 Typert 的 Host-for-Client 投影强约束。
  • 新包只进一个聚合;api/remotes 是唯一的双 tsconfig 特例。
韧性

15. 错误、取消、恢复与持久化

生产 Agent 一定会遇到:用户取消、模型抽风、工具超时、进程崩溃。DeepSeek Harness 的应对都围绕一个支点——Session Log 是事实来源

15.1 错误路径

  • 工具执行异常:错误作为 tool/result 事件的 error 字段入日志;模型可在下一步读到错误并自我纠正。
  • 模型请求失败:LLM 适配器抛错 → step 失败事件 → 可重试或结束 turn。
  • 未捕获异常:循环记录失败并结束 turn,已落盘日志保留,便于事后排查。

15.2 取消机制

AgentHandle.cancel() 在当前 step / 工具执行处注入中断。已完成的 step 与写入日志的部分保留;未完成的 step 不记录(或记中断事件)。这与"日志=事实"一致:取消只是"停止继续写新事实"。

15.3 恢复(Resume):从日志续跑

因为一切事实都在日志里,Resume = 加载日志 → 重建 Context 状态 → 从末尾继续 claim 新输入。不需要保存"当时的 messages 数组快照",因为 messages 本就是日志的投影,重放即得。

第一层
第二层

像游戏存档:你不需要保存"屏幕画面",只要保存"发生了哪些事件",重新播放就能回到原处继续玩。

持久化层把日志落盘(JSONL,Windows 上用 MoveFileExW 写透保证 durability);Resume 时加载该 JSONL,deriveMessages() 重建上下文,Agent Loop 接着跑。Fork 则是复制前缀日志做分支实验。

15.4 凭据与配置隔离

机制说明
.env环境变量分层:调用目录文件 > Harness-home 文件 > 继承环境;loadLayeredEnv 记录每值来源
.credentials.yaml受管凭据单独存放;落在 .env 的凭据只是低优先级 fallback
Telemetry从日志派生统计(token、耗时、工具调用),与状态存储同源
生产坑:把 API Key 提交进 .env 并 push 是高频事故。项目明确"禁止提交真实凭证",凭据走 .credentials.yaml(gitignored)。恢复/分叉前务必确认日志里不含明文密钥(post-execute 的 finalizeContent 脱敏在此发挥作用)。

本章要点

  • 错误/取消都围绕"日志即事实":已完成的事实保留,未完成的停止写入。
  • Resume = 加载日志 + 重建状态 + 续跑;Fork = 复制前缀。
  • 凭据与配置分层隔离,日志落盘(JSONL,Windows 写透),遥测从日志派生。
可执行设计文档

16. 测试所揭示的架构不变量

作者说"把测试当作可执行设计文档"。读测试能确认:系统真正保证了什么、哪些行为是不可破坏的核心不变量、哪些边界被重点防守。

16.1 测试体系(来自 development.md)

  • vitest 为单元测试/集成测试框架;pnpm run check:all 跑本地综合门。
  • 真实 API e2epnpm run test:e2e)在 DEEPSEEK_API_KEY 未设置时 self-skip——这是"无密钥也不报错"的工程自觉。
  • 文档中的 ts type-equiv 围栏由 pnpm run verify-type-equiv 校验,保证文档里的类型片段与真实代码一致。

16.2 从文档反推的核心不变量

不变量证据(源码/文档)若被破坏会怎样
配置 composition / flag / dump 不漂移composeEntries 用同一 applyEntryPatchesrenderConfigDump 复用 boot 算法dump 看到的不等于实际挂载 → 排查无据
模型可见即被记录session.md "Model-visible means logged"重放/审计时上下文漂移
waterfall 监听器必须 next()cordis-primer.md 强调 dispatch 模式事件链卡死,后续段永不执行
插件 effect 卸载即回滚Cordis effect + 无 privileged core 纪律资源泄漏、能力污染
每个 entry 必须 load 且 activateassertEntriesLoaded/Activated静默失效的插件导致运行期缺失能力
scope 隔离 per-agentctx.xxx.scope(agentCtx)跨 Agent 误用工具/提示
阅读建议:想确认某个不变量,先找对应子系统文档里"MUST/必须/always"的陈述,再去 packages/*/testtest/ 找断言它的用例。断言失败即"架构被破坏",比注释更有约束力。

本章要点

  • 测试是"不可破坏契约"的承载者;e2e 无 key 自跳过是工程自觉。
  • 六条不变量(不漂移、可见即记、next()、effect 回滚、entry 激活、scope 隔离)是读源码时的"校验清单"。
默会知识

17. 代码不会主动告诉你的事(默会知识)

以下每条都按"现象 / 底层原因 / 实际风险 / 判断方法 / 推荐做法 / 证据"六要素组织。部分属工程推断已标注。

17.1 架构默会知识

① 作者真正守住的架构不变量:

现象:文档反复强调"一切皆插件""日志即事实""composition/flag/dump 不漂移"。
原因:这三条是让系统"可组合、可重放、可审计"的支柱。
风险:为图省事在核心写死一个能力,会悄悄破坏可替换性。
判断:看到一个能力是"硬编码调用 SDK"还是"走 seam",就知道它在不在不变量内。
推荐:任何新能力优先做成插件 + seam,除非有性能硬约束。
证据:docs/architecture.md 设计原则、app-bootcomposeEntries

② "一切皆插件"既是能力也是风险:

现象:插件能改行为、能挂监听、能起副作用。
原因:扩展点过于开放。
风险:插件之间形成隐式耦合(A 依赖 B 的事件顺序),卸载 B 让 A 失效。
判断:用 --dump-config 看插件依赖图;警惕"监听了别人的内部事件"。
推荐:插件只依赖公开 seam/事件契约,不依赖其他插件的内部实现。
证据:cordon-primer 的 dispatch 模式;默示的"无 privileged core"纪律。

③ 必要复杂度 vs 偶然复杂度:

现象:Event Log、Typert、双 aggregate 看起来很重。
原因:Event Log 支撑可重放/审计(必要);Typert/双 aggregate 是"浏览器+Node 共享 Cordis Context"带来的约束(必要但源于技术选型)。
风险:把必要复杂度误当过度设计而删掉,会失去可恢复性。
判断:问"去掉它还能不能重放/还能不能双端跑?"能→必要;否则→可简化。
推荐:保留 Event Log 与 Remote 契约;评估是否真需要某优化。
证据:docs/development.md 构建顺序、session.md Event Sourcing。

17.2 源码阅读默会知识

④ 最有效的入口顺序:

推荐:READMEdocs/architecture.md → 子系统文档(session/tools/core/system-prompt/scope)→ packages/boot/app-boot(组合)→ 具体 packages/core/* 源码 → 对应 test
原因:先建立"概念地图"再钻源码,避免被 Monorepo 的目录数量淹没。
判断:若一上来就读 packages/core/agent-loop 会迷失在类型里。
推荐:沿"类型 → 事件 → 测试 → 注册点"追踪调用链。

⑤ 识别自动生成文件:

现象:api/remotes/remote 声明、Typert 反射产物是生成的。
原因:dsh:doc:gen-cordis-catalog、Typert 在 Host 构建期生成。
风险:改生成文件会被下次构建覆盖。
判断:看是否有生成脚本/注释标记;改源头(@Remote 装饰)而非产物。
证据:package.jsondoc:gen-cordis-catalogdevelopment.md Typert 段。

17.3 生产实践默会知识

⑥ 配置看似生效、实际没挂载:

现象:改了 cordis.patch.yml 但行为没变。
原因:补丁 id 对不上 composed tree 的 entry id(只警告不报错);或 patch 被更高层覆盖;或文件为空/只有注释(会抛错,需 [] 禁用)。
风险:静默无效,排查耗时。
判断:pnpm dsh --dump-config --profile X 看该 entry 是否出现、config 是否正确。
推荐:改完先 dump 再启动;patch 用整段替换要重述保留字段。
证据:app-boot README 的 patch 段与限制。

⑦ 该新增插件、监听事件、提供 Service,还是改 Agent Loop?

判断树:
- 增加"一类能力"→ 新插件 + seam;
- 增加"对现有流程的横切处理"(审批/日志/脱敏)→ 监听事件(pre/post);
- 暴露"一个可被调用的接口"→ 提供 Service;
- 只有"循环本身的生命周期逻辑"才动 Agent Loop,且尽量不耦合具体能力。
推荐:默认不动 Loop;能用事件/插件解决的,不要改核心。

本教材标注说明:第 17 章中"现象/原因/风险/判断/推荐"基于文档与架构推理整理;凡涉及作者未明写的动机,均按 工程推断 处理,请勿当作官方声明。已确认的事实(如 event 名、构建顺序、dump==boot)均有对应文档出处。
批判性分析

18. 设计取舍与替代方案(批判性对比)

不只要说它好。下面按你关心的维度对比:状态所有权、扩展机制、可恢复性、可审计性、生命周期、工具治理、配置组合、学习成本、运维复杂度、适用场景。

18.1 与"自己写简单 Agent Framework"对比

维度简单 ReAct while 循环DeepSeek Harness
状态所有权内存 messages 数组append-only Session Log(事实源)
扩展机制改代码挂插件 / 监听事件 / 提供 seam
可恢复性需自己存快照日志重放/续跑/Fork
可审计性日志即审计
工具治理手写 ifpre/execute/post 事件管线
配置组合硬编码Profile/Bundle/Patch 分层
学习成本高(Cordis/Typert/scope/seam)
运维复杂度较高(构建链、双 aggregate)
适用场景Demo、一次性脚本长期运行、多入口、需合规的生产 Agent

18.2 与常见方案在抽象层上的差异(外部比较

方案抽象重心与 DeepSeek Harness 的差异
LangGraph有向图 / 状态机编排显式图结构;Harness 用事件驱动的插件树,图是派生的
AutoGen多 Agent 对话编排以"对话组"为核心;Harness 以"运行时+日志+seam"为核心
OpenAI Agents SDKAgent/Tool/Handoff 原语更轻量、少"事件日志即真相"的强约束;Harness 把可重放放在第一位
注意:以上为抽象层次的对比(基于公开产品定位与本项目文档的架构取向),非逐功能对照表。具体取舍请以各项目最新文档为准;本教材不主张某一方案"更好",只说明它们在"状态所有权/可恢复性"上的取向不同。

18.3 关键架构取舍的结论

  • 插件式 vs 单体循环:插件式胜在可组合/可测/可替换,代价是插件治理与学习成本。Harness 选择插件式并把"无特权核心"作为纪律。
  • Event Log vs 最终状态:Log 胜在可重放/审计/分叉,代价是体积与裁剪。Harness 把 Log 当事实源。
  • 事件驱动 vs 直接调用:事件驱动解耦、可拦截,代价是调用链不直观(需 next() 纪律)。
  • Profiles/Bundles/Patch vs 固定配置:分层组合胜在"替换能力不改系统",代价是补丁语义要严格(整段替换)。
  • Capability Seam vs 内联 SDK:seam 胜在可替换/可测/可远程,代价是多一层接口。
  • ReAct Loop vs Workflow/DAG:ReAct 灵活、适合开放任务;DAG 可控、适合固定流程。Harness 内核是 ReAct 式循环,图可由上层派生(工程推断)。

本章要点

  • Harness 在"可组合/可重放/可审计"上投入必要复杂度,换取生产化能力。
  • 与 LangGraph/AutoGen/OpenAI Agents SDK 的差异主要在抽象重心,而非功能多少。
  • 没有"最好",只有"是否匹配你的状态所有权与恢复需求"。
动手

19. 六个渐进式动手实验(Labs)

从"跑起来"到"做一个小型生产化 Agent"。每个 Lab 都有:学习目标 / 前置知识 / 操作步骤 / 涉及文件 / 核心代码 / 预期结果 / 验证方法 / 常见错误 / 思考题 / 完成标准。

环境说明本教材未实跑;步骤基于 docs/development.md 与子系统文档静态确认
Lab 0 · 运行项目(见第 2 章,此处略去重复)
目标/步骤/验证已在 §2 给出:clone → install → build → pnpm dsh --profile web--dump-config 看插件树。
Lab 1 · 追踪一次完整请求

学习目标:把第 6 章的时序图映射到真实代码。
前置:§6–§7。
步骤:① 在 packages/core/agent-loopturn/start / step/start 触发点;② 在 packages/core/sessionderiveMessages;③ 在 packages/core/toolstools/pre-execute / tools/execute / tools/post-execute / tools/result 事件触发点;④ 在 packages/llmctx.llm.call;⑤ 用日志/断点记录一次请求的调用链。
涉及文件:packages/core/agent-looppackages/core/sessionpackages/core/toolspackages/llm/llm
核心动作:画一张你自己的调用链笔记(输入→inbox→turn→step→prompt→llm→tool→log→turn/end)。
预期:能指出每个事件名对应的代码位置。
验证:对照 §6 时序图,标注每一步在你源码里的函数名。
常见错误:只看类型声明不看实现;忽略 waterfall 的 next() 调用点。
思考题:若模型一次返回 3 个 tool call,step 内是如何逐个执行并回写的?
完成标准:产出一份带文件:行号的调用链笔记。

Lab 2 · 开发一个最小 Tool

学习目标:理解工具注册、Schema、执行、结果、错误处理。
前置:§10。
核心代码(当前时间工具,来自 §10):

ctx.tools.register({
  name: 'current-time',
  description: '返回当前 ISO 时间',
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
  execute: async (toolCtx, args) =>
    ({ content: [{ type: 'text', text: new Date().toISOString() }] }),
});

步骤:① 新建一个插件包并在 cordis.patch.yml 里 insert 该插件;② build;③ pnpm dsh --profile web 在对话框测试"现在几点";④ 加一个会抛错的路径验证 tool/resulterror 字段。
涉及文件:你的插件包、packages/core/tools
预期:模型能调用该工具并返回时间;错误路径进日志。
验证:--dump-config 能看到该工具 entry;对话中模型成功调用。
常见错误:schema 与 execute 返回结构不符;忘了 additionalProperties:false 导致注入宽松。
思考题:工具的 schema 是如何从 ctx.tools 进入 System Prompt 的?(提示:ctx.tools.schemas(scope)
完成标准:一个可调用、有错误处理、出现在 dump 里的自定义工具。

Lab 3 · 开发一个模型上下文插件

学习目标:让插件向指定 Agent 注入动态上下文,并观察它进入下一次模型请求。
前置:§9。
核心代码(来自 §9):

ctx.systemPrompt.section({
  name: 'project-rules',
  order: 100,
  scope: agentScopeKey,
  content: `当前项目规则:TypeScript strict;Conventional Commits。`
});

步骤:① 注册该 section;② 在 Web 里发请求,观察模型是否"遵守规则";③ 用 --dump-config 与日志确认该段被注入。
验证:模型回复体现出注入的规则;换一个 agent(scope 不同)不应看到该段。
常见错误:忘记 scope 导致对所有 agent 生效;section 顺序导致被覆盖。
思考题:如果注入内容随仓库变化,如何让它"每次请求实时重算"而非写死?
完成标准:一段仅对目标 agent 可见、进入下一次模型请求的动态提示。

Lab 4 · 实现一个 Capability Provider

学习目标:体验 Definition / Provider / Consumer 的分离。
前置:§11。
推演(以 fs seam 为例):

// Provider 侧:把本地 fs 换成"远程沙箱"实现(接口不变)
ctx.fs.provide({
  read: async (path) => rpcToSandbox('read', path),
  write: async (path, data) => rpcToSandbox('write', path, data),
});
// Consumer 侧:所有调用 ctx.fs.read 的代码一行不改

步骤:① 找一个走 ctx.fs 的现有能力;② 写一个新 Provider 用 mock/远程实现 provide;③ 验证 Consumer 行为不变。
验证:切换 Provider 后,上层调用结果与预期一致,无需改 Consumer。
常见错误:把实现细节(如 RPC 序列化)泄露进 Definition 接口;Consumer 直接 import 了具体实现类。
思考题:如果 Provider 暂时不可用,Consumer 应得到什么(错误事件?降级?)?
完成标准:同一个 Consumer 在"本地/远程"两种 Provider 下都能工作。

Lab 5 · 增加一个可持久化事件

学习目标:扩展事件类型、写入日志、从日志投影状态、测试 Replay/Resume。
前置:§8。
步骤:① 在 SessionEvent 联合类型中加一个新 variant(如 custom-marker);② 在流程中 session.append({type:'custom-marker',...});③ 在 deriveMessages 的投影逻辑里处理它(决定它是否/如何进入模型消息);④ 写测试:写入→重放→断言状态重建。
涉及文件:packages/core/session(event 类型、deriveMessages)、对应 test。
验证:Replay 后该事件被正确投影;Resume 从该点续跑无误。
常见错误:加了事件类型却忘了更新投影 → 重放时丢失;事件含不可序列化字段(无法落盘)。
思考题:哪些新事件应该"进模型消息",哪些只做元数据(不进 messages)?
完成标准:一个新事件类型,能落盘、能投影、有 Replay 测试。

Lab 6 · 一个小型生产化 Agent(可恢复的代码仓库分析 Agent)

学习目标:综合运用工具调用 + 会话记录 + 中断恢复 + 权限控制 + 错误处理 + 基本可观测性。
前置:§6–§15 全部。
要求清单:

  • 工具:只读代码查询工具(Lab 2 升级)、可写工具需 approval(§10 pre-execute)。
  • 会话:所有交互进 Session Log,支持中断后用 --dump-config-style 的 resume 续跑(§15)。
  • 权限:写操作走 tools/pre-execute 审批/沙箱(§10,§12)。
  • 错误处理:工具失败进 tool-error 事件,模型可纠偏(§15)。
  • 可观测:从日志派生 token/工具调用统计(§15 Telemetry)。
  • 恢复:进程崩溃后加载日志 JSONL 续跑(§15)。

验证:① 跑一轮分析;② 中途 Ctrl-C;③ 重启 resume,能从断点继续且上下文一致;④ 检查日志可审计。
常见错误:把 API Key 写进会被持久化的日志;resume 时重复执行已完成的写操作(需幂等)。
思考题:如何保证"已写入文件的副作用"在 resume 时不会被重复执行?(提示:幂等 + 日志记录副作用结果)
完成标准:一个能中断恢复、有权限控制、日志可审计的最小生产化 Agent。

Labs 总览

  • Lab 0 运行 · Lab 1 追踪请求 · Lab 2 最小工具 · Lab 3 上下文插件
  • Lab 4 能力 Provider · Lab 5 可持久化事件 · Lab 6 生产化 Agent
  • 难度递增;每个 Lab 都可单独验证;建议按顺序完成。
导航

20. 源码阅读地图(以 47f9438 为准)

所有链接指向 master@47f9438。建议按"概念 → 子系统文档 → 包源码 → 测试"的顺序。

你想理解先读文档再读源码(链接为 tree 根)
整体定位README.md仓库根
架构全景docs/architecture.mdpackages/
Session / 事件日志docs/subsystems/session.mdpackages/core/session
Agent Loopdocs/subsystems/core.mdpackages/core/agent-loop
Toolsdocs/subsystems/tools.mdpackages/core/tools
System Promptdocs/subsystems/system-prompt.mdpackages/core/system-prompt
Scopedocs/subsystems/scope.mdpackages/core/scope
LLM 缝docs/subsystems/llm-streaming.mdpackages/llm
Profiles/Bundlespackages/boot/app-boot/READMEpackages/boot/app-boot
CLi 入口apps/cli
构建系统docs/development.mdtsconfig.host.json / tsconfig.client.json
阅读纪律:① 先文档后源码;② 沿"类型 → 事件 → 测试 → 注册点"追踪;③ 自动生成文件(api/remotes/remote、Typert 产物)改源头别改产物;④ 用 --dump-config 验证"实际挂载"。
自测

21. 费曼复述与分级练习题

21.1 费曼复述(用自己的话讲出来)

先合上教材,尝试解释下列问题;展开看"合格答案要点 / 常见错误 / 追问方向"。

① 什么是 Agent Harness?

合格要点:一个把"模型调用、工具执行、会话状态、能力替换、UI"可靠组装起来的运行时;它不直接实现模型或 UI,而是让 Agent 能长期、可恢复、可审计地运行。关键词:运行时 / 编排层 / 非模型非 UI。

常见错误:说成"一个具体的 Agent"或"一个模型封装库"。

追问:Harness 和 Agent Framework 的边界在哪?

② 为什么"一切皆插件"?

合格要点:让"增加/移除能力"="挂/卸插件",且卸载时 effect 自动回滚;核心循环保持精简、可测、无特权核心。

常见错误:以为"插件化只是方便组织代码",忽略"卸载可逆"与"无特权核心"的纪律。

追问:插件化带来的最大风险是什么?

③ Turn 和 Step 的区别?

合格要点:Turn=一次用户工作单元;Step=其中一次"模型请求+其工具调用"。一个 Turn 可含多个 Step(模型多轮 ReAct)。

常见错误:把 Turn 和 Step 当成同义词,或以为"一个工具调用=一个 Turn"。

追问:为什么模型一次返回多个 tool call 仍属于同一个 Step?

④ 为什么模型可见内容必须被记录?

合格要点:保证跨机器/断点重放时模型看到的上下文一致(可重放、可审计、可恢复);否则日志重建出的上下文会与当初不符。

常见错误:以为"只要保存最终 messages 数组就够了"。

追问:"System Prompt 的拼接过程"要不要记录?为什么?

⑤ Capability Seam 如何实现可替换能力?

合格要点:Definition(接口)+Provider(实现)+Consumer(使用) 三者分离;换 Provider(本地→远程)Consumer 不变。

常见错误:以为"只要有个接口就是 seam",忽略 Provider/Consumer 齐备。

追问:只有工具(没有服务层)为什么不算完整 seam?

⑥ 为什么 UI 不能成为状态真相来源?

合格要点:真相在 Session Log;UI 只是日志的视图。若 UI 自己存状态,多端/刷新/断线会与日志不一致,且无法重放审计。

常见错误:把"前端展示的对话"当成可恢复的状态。

追问:Client 通过什么机制安全地"看"到 Host 的状态?

21.2 分级练习题

【L1 选择】DeepSeek Harness 处于技术栈的哪一层?

答案与解析
B。Harness 不直接实现模型能力,也不实现 UI,它是运行时与编排层(见 §1.2)。

【L1 判断】"一个 Turn 只能包含一个 Step。"

答案与解析
错误。Turn 是用户工作单元,可含多个 Step(模型多轮调用工具)。见 §6.2。

【L2 源码定位】配置组合、flag 派生、配置转储三者"不能漂移"靠哪个函数保证一致?

答案
composeEntries 用 include 自己的 applyEntryPatchesrenderConfigDump 复用同一算法,所以 composition/flag/dump 一致。见 §5.4、§16。

【L3 排序】把一次请求的关键步骤按正确顺序排列:

A. 模型流式输出 B. 工具执行后处理(post) C. Inbox 接收输入 D. 历史消息从日志派生 E. Turn 开始 F. 工具执行前拦截(pre) G. 工具结果写入日志 H. Step 开始

答案
C → E → H → D(与 prompt 组装并行)→ A → F →(execute)→ B → G。对应 §6.1 时序图:③ Turn 开始、④ Step 开始、⑥ 历史、⑧ 流式、⑩ pre、⑪ execute、⑫ post、⑬ result。

【L3 代码阅读】下面这段在 waterfall 事件里漏了什么会导致事件链卡死?

ctx.on('system-prompt/assemble', (assembly, context, next) => {
  // 修改 assembly(如注入额外 section)
  // 漏了 next(assembly)
});
答案
没有调用 next(sections)。waterfall 事件必须调用 next() 把累积结果传给下一个监听器,否则后续段永不执行。见 §4.3、§16。

【L4 故障排查】你给 cordis.patch.yml 加了一个工具 entry,重启后行为没变,--dump-config 里也找不到它。可能原因有哪些?如何确认?

答案
可能:① patch 的 id 对不上 composed tree 的 entry id(只警告);② 被更高层 patch 覆盖;③ 文件为空/只有注释(会抛错,需用 [] 禁用);④ 该插件未在 node_modules 解析到。确认:pnpm dsh --dump-config --profile X 看 entry 是否出现、config 是否正确;检查 stderr 警告。见 §17 ⑥、§5。

【L4 架构设计】如果要让"本地文件系统能力"在不动任何 Consumer 的前提下切换成"远程沙箱文件系统",你应该改动哪一层?为什么 Consumer 不用改?

答案
只改 Provider:把 ctx.fs.provide 的实现从本地换成远程 RPC;Definition 与 Consumer 不变(Consumer 只依赖 ctx.fs.read/write 接口)。这是 Capability Seam 的价值。见 §11.4。

练习建议

  • L1–L2 检验概念与定位;L3 检验源码与调用链;L4 检验设计与排障。
  • 先闭卷做,再展开答案;错题回到对应章节重读。
面试

22. 面试题与回答框架(15+)

Q1 · 请用一句话说明 DeepSeek Harness 是什么,以及它解决了什么问题。

考察:整体理解、能否区分 Harness 与模型/Framework。

60 秒框架:定位(运行时/编排层)→ 问题(Demo 的 while 循环缺可重建上下文/可替换能力/工具治理/可观测可恢复)→ 解法(一切皆插件 + 事件日志即真相 + 能力缝)。

优秀答案:"它是 Cordis 插件化的 Agent 运行时,用 append-only 事件日志作为模型上下文事实来源,让模型/工具/存储/沙箱/UI 可替换而不改核心循环;解决的是生产化 Agent 在可恢复、可审计、可组合上的难点。"

追问:它和 LangGraph 的本质区别?

易暴露:说成"就是一个调用 LLM 的封装"或"和 AutoGen 一样"。

Q2 · "一切皆插件"具体意味着什么?有什么代价?

考察:插件架构动机与权衡。

框架:定义(Plugin/Service/Event/Effect 挂共享 Context)→ 价值(增删=挂卸、effect 可逆、无特权核心)→ 代价(插件隐式耦合、学习成本、治理)。

优秀答案:强调"卸载时反向撤销"与"核心能力本身也是插件",并点出风险:插件监听彼此内部事件会造成隐式耦合。

追问:如何防止插件之间形成隐式耦合?

Q3 · 描述一次请求的完整 Turn Flow。

考察:Agent Loop 是否真懂。

框架:输入→Inbox→Turn start→Step start→prompt 组装+历史派生→LLM 流式→tool call→pre/execute/post→result 入日志→判断是否下一 Step→Turn end。

优秀答案:能区分持久化事实(turn/start、tool-result、turn/end)与运行时派生(prompt 拼接、流式渲染),并说明 waterfall 的 next() 纪律。

追问:若模型一次返回 3 个 tool call,循环如何处理?

Q4 · 为什么用 Event Log 而不是保存最终 messages 数组?

考察:Event Sourcing 理解。

框架:重放/审计/分叉/恢复的需求 → 只存终态会丢失中间态 → Log 即事实,messages 是投影。

优秀答案:提到 Replay/Resume/Fork/Transcript/Telemetry 都从同一份日志派生,并指出代价(体积、裁剪)。

追问:日志太大会怎么办?

Q5 · "Model-visible means logged" 你怎么理解?

考察:可重放一致性的边界。

框架:任何模型看到的内容必须能从日志重建 → 跨机器/断点重放一致 → 区分"事实"与"拼接过程"。

优秀答案:能举例:System Prompt 最终内容要可重放,但其 waterfall 拼接过程不存(可重算)。

Q6 · 工具执行管线如何插入权限/审批/沙箱?

考察:工具治理与解耦。

框架:tools/pre-execute(allow/deny/ask)→ tools/executetools/post-execute(accept/block)→ finalizeContent 脱敏 → tools/result 入日志;治理以插件挂 pre/post,不耦合循环。

优秀答案:指出 Agent Loop 只 await tools/result,不关心治理细节。

Q7 · Scope 解决了什么问题?没有它会怎样?

考察:隔离与生命周期。

框架:全局 vs per-agent Context → 作用域化注册(tools/prompt/user/session)→ 卸载时 effect unwind → 无 scope 会能力污染/泄漏/互相干扰。

优秀答案:用"多 Agent 场景下工具被错误 Agent 调用"的具体故障说明。

Q8 · Capability Seam 为什么需要 Definition+Provider+Consumer 三者?

考察:可替换能力的设计。

框架:只有接口跑不起来 / 只有实现无法替换 / 只有工具无法被复用远程化 → 三者齐备才"可替换可测可组合"。

优秀答案:能用"本地 fs→远程沙箱,Consumer 不变"推演。

Q9 · 为什么 Host 和 Client 分成两个 TypeScript aggregate?

考察:构建系统与架构约束。

框架:同 key 下 Cordis Context 声明合并在单 program 冲突 → 拆 host/client 两个 program → 构建顺序由 Typert 的 Host-for-Client 投影约束。

优秀答案:提到 api/remotes 是唯一双 tsconfig 特例,新包只注册一个聚合。

Q10 · 构建顺序为什么是 Host→Client?

考察:理解 Typert 的作用。

框架:Typert 只在 Host tsdown 跑,生成 Host 反射与 Remote 投影;Client 消费该声明 → Host 必须先。

优秀答案:说明这不是随意顺序,而是"Client 依赖 Host 远程契约"的强约束。

Q11 · 错误、取消、恢复分别如何工作?

考察:韧性设计。

框架:错误→事件入日志模型可纠偏;取消→中断当前 step,已完成事实保留;恢复→加载日志+重建+续跑。

优秀答案:强调"都围绕日志即事实",而非保存 messages 快照。

Q12 · 你会在什么情况下选择自建简单 Agent,什么情况下用 Harness?

考察:取舍判断。

框架:Demo/一次性脚本→简单 while;长期运行/多入口/需合规恢复→Harness。

优秀答案:明确"学习成本与运维复杂度"的 traded-off。

Q13 · 如何排查"配置看似生效、实际没挂载"?

考察:实战排障。

框架:--dump-config 看真实树 → 检查 patch id 匹配 / 层覆盖 / 文件空 → 看 stderr 警告。见 §17 ⑥。

Q14 · waterfall 事件里监听器不调用 next() 会怎样?系统如何防守?

考察:对事件模型的细节掌握。

框架:事件链卡死,后续段不执行 → 这是作者重点防守的不变量(§16);测试应断言监听器行为。

Q15 · 如何设计一个"可回放、可恢复、可审计"的 Agent?

考察:综合生产化思维。

框架:① 所有事实进事件日志 ② 工具副作用幂等+记录结果 ③ 能力走 seam 可替换 ④ UI 只订阅 ⑤ 凭据与日志隔离 ⑥ 从日志派生遥测。

优秀答案:能把第 8/10/11/15 章串成一套设计原则。

追问:副作用(写文件)如何在 resume 时避免重复执行?

Q16(加餐)· 它的抽象层与 LangGraph/OpenAI Agents SDK 有何不同?

考察:生态定位(外部比较)。

框架:Harness 以"运行时+事件日志+seam"为核心、图可派生;LangGraph 以显式图/状态机为核心;OpenAI Agents SDK 更轻量、少强日志约束。

优秀答案:强调差异在"状态所有权与可恢复性取向",不主张谁更好。

收尾

23. 下一步学习路线与术语表

23.1 下一步

  • 动手:完成 §19 的 6 个 Lab,尤其 Lab 6 的生产化 Agent。
  • 读源码:按 §20 地图,从 packages/core/sessionagent-loop 入手,对照测试。
  • 对比:用同一任务分别用"简单 ReAct"和"Harness"实现,体会差异。
  • 跟进:项目处于 Developer Preview(0.1.0-rc.5),接口会变;订阅仓库 release 与 .agents/notes 的 Agent Notes。
  • 延伸:读 Cordis / cosmokit / schemastery 上游文档,理解插件内核本身。

23.2 术语表

术语含义
HarnessAgent 运行时/编排层,把模型、工具、状态、能力、UI 组装运行
Cordis事件驱动插件内核(Plugin/Service/Event/Effect)
Profile / Bundle / Patch配置档 / 声明 patch 的 npm 包 / id 定向整段替换的配置层
Turn / Step用户工作单元 / 单次"模型请求+工具调用"
Session Event Logappend-only 事实来源;messages 是其投影
deriveMessages把事件日志投影为模型可见消息数组
Capability SeamDefinition+Provider+Consumer 组成的可替换能力接缝
Scopeper-agent 上下文派生,隔离工具/提示/会话
waterfall事件分发模式,监听器必须 next(),可改写/短路
TypertHost 构建期工具,生成 Host-for-Client Remote 投影
finalizeContent工具后处理阶段,可裁剪/脱敏结果

23.3 源码索引(按主题)

  • 配置组合:packages/boot/app-bootboot/composeEntries/renderConfigDump/assertEntriesLoaded/Activated
  • 循环:packages/core/agent-loopattach/run/startTurn/startStep
  • 会话:packages/core/sessionSessionEvent/deriveMessages
  • 工具:packages/core/toolsregister/schemas/tools/pre-execute · tools/execute · tools/post-execute · tools/result
  • 提示:packages/core/system-promptsection/context/assemble · ctx.tools.schemas
  • 范围:packages/core/scopescope(agentCtx)
  • LLM 缝:packages/llmctx.llm.call/适配器
  • 入口:apps/cli/src/bin.tsparseDshArgs/runProfile/runDumpConfig

本教材基于:deepseek-ai/deepseek-harness · master@47f943859bef60e4160492346772ded9b24f765a · 2026-08-13 · dsh@0.1.0-rc.5(Developer Preview)。

验证状态:✔ 已通过 GitHub API/raw 实际确认:commit SHA、版本号、README/AGENTS/architecture/development 及子系统文档内容、CLI 入口结构。未在本环境实跑:clone、install、build、test、web/headless 启动(无网络 clone 权限与 DEEPSEEK_API_KEY)。所有"运行/构建/测试"结论为静态确认;凡动机性判断均标注 工程推断

使用提示:左侧目录可跳转与高亮;右上角可搜索、切换深浅色、勾选"标记已学"保存进度;代码块右上角可复制。如 commit 已更新,请按 §20 地图以相同入口重新核对。