vbnet
title: "DeepSeek Harness 上下文拼接:让 AI 准确行动"
subtitle: "模型看到的上下文是三层拼出来的:系统提示词 + 规则记忆 + 动态快照"
description: "模型每次请求看到什么?不只是你发的那条消息------还有系统提示词、项目规则(AGENTS.md)、动态快照,它们一起拼成模型眼前的上下文。DeepSeek Harness 把上下文拼成三层:Sections(系统提示词,System 角色)、Agent Instructions(规则与记忆,User 角色)、Contexts(动态快照,User 角色)。本文讲清三层各自的内容、拼装流程(preStep 三步)、以及最终消息顺序。"
summary: "拆解 DeepSeek Harness 的上下文拼接:Sections / Agent Instructions / Contexts 三层结构、preStep 拼接流程、最终消息顺序。"
keywords:
- DeepSeek Harness
- 上下文拼接
- System Prompt
- Agent Instructions
- AI Agent 框架
tags:
- 源码解析
- AI Agent
- TypeScript
categories:
- 深入理解 DeepSeek Harness
slug: "deepseek-harness-context-assembly"
series:
- "深入理解 DeepSeek Harness"
series_weight: 2
toc: true
DeepSeek Harness 上下文拼接:让 AI 准确行动
「深入理解 DeepSeek Harness」系列第 2 篇。全系列地图见第 0 篇《5 分钟看懂 DeepSeek Harness》。
引言
模型每次请求,看到的不是只有你发的那条消息。
它还看到:系统提示词("你是资深架构师")、项目规则(AGENTS.md 里写的"改代码先读测试")、记忆、当前打开的文件的快照------这些全部拼在一起,才是模型眼前的"上下文"。而上下文长什么样,直接决定模型的表现。
问题来了:这段上下文从哪来?把上下文拆成几段拼起来,是业内共识。真正的差别在谁在拼、怎么拼------多数框架的拼装逻辑写死在框架内部,加一段内容就要改框架代码。
DeepSeek Harness 把上下文拆成三层,各自由不同的插件贡献:
| 层 | 角色 | 内容 | 谁贡献 |
|---|---|---|---|
| Sections | System | 系统提示词:身份、性格、工具引导 | 工具插件、身份插件 |
| Agent Instructions | User | 规则与记忆:AGENTS.md、CLAUDE.md | agent-instructions 插件 |
| 动态上下文(Context) | User | 动态快照:委托状态、沙箱策略、审批状态 | context 插件 |
模型看到的上下文是三层拼出来的,不是哪一层单独写出来的。
阅读门槛
前置知识 :需要 TypeScript 基础(能读懂
abstract class、generics、declaration merging),了解 ReAct Agent 基本概念(LLM 交替推理与行动的循环模式)。适合:想要阅读 DeepSeek Harness 源码、做 Agent 框架二次开发的开发者。
不适合:零基础想学习大模型 Agent 入门的读者------建议先了解 ReAct 模式和 TypeScript 插件架构再回来。
下面按"三层各是什么 → 每层怎么拼 → 完整拼接流程 → 最终消息顺序"拆。
一图总览

图 1:三层内容汇入 preStep,拼成模型请求的两个字段------system(静态前缀)和 messages(用户输入、指令、快照、历史)。实线 = 贡献,虚线 = 拼装结果。
一、三层结构------解决"模型请求的上下文从哪来"
先分清两个东西:system-prompt 是模块名 (SystemPrompt 服务------注册 Sections 的注册表,负责收集、排序、渲染),不是拼好的提示词 。拼好的结果叫 system 文本 ,是模型请求里 system 字段那段文字。打个比方:system-prompt 是"拼图板 + 拼装工人",system 文本是"拼好的图"。下面讲的三层,就是往拼图板上放的三类拼图块。
Sections(系统提示词,System 角色) 。静态的系统级指令,三类:身份(真实文本是 "You are an AI agent powered by DeepSeek Harness.")、性格(persona,部署配置)、工具引导(比如 tool:read 的 "Use the read tool --- not shell commands like cat --- to inspect text files.")。按 order 排序拼成一段 system 文本------这是模型的稳定前缀,每次请求一样,命中 LLM 缓存。每类的真实例子见第二节。
Agent Instructions(代理指令,User 角色) 。项目级规则和记忆:AGENTS.md、CLAUDE.md、工作区规则。它动态加载 ------每次请求前从磁盘读,工具改动这些文件时自动重载。放 User 角色而不是 System,因为它不能污染稳定前缀:规则文件会变,塞进 system 会让缓存全部失效。
动态上下文(Context,User 角色) ------源码里叫 dynamic context。动态生成的运行时状态快照:子代理委托上下文、沙箱策略状态、用户审批状态。放 User 角色,变化才注入,被替换时打"已清除"标记。
三层分工一句话:System 管"你是谁、你能做什么",User 管"项目规则、当前状态" 。
二、Sections:系统提示词怎么拼
Section 是什么?
在 DeepSeek Harness 里,系统提示词不是一大段写死的字符串,而是由很多小块拼起来的。每个小块就叫 Section------一段有名字、有优先级、有内容的提示词片段。
接口定义长这样(system-prompt/index.ts:53):
typescript
export interface PromptSection {
/** 唯一名字------重复注册会报错(比如 tool:read 只能有一个)。 */
readonly name: string
/** 优先级,按升序拼接。约定:-100 是身份,0 是性格,100-199 是工具引导。 */
readonly order: number
/** 内容:静态文本,或每次拼装时求值的函数(可引用 {{variable}})。 */
readonly text: string | ((context: AssembleContext) => string)
}
三个字段各管一件事:
- name:唯一标识,同一作用域内重复注册会直接报错。
- order:拼接顺序,数字越小越靠前。约定是 -100 身份,0 性格,100-199 工具引导。
- text :可以是固定文本,也可以是每次拼装时才求值的函数,支持
{{variable}}变量插值。
一句话:Section = 一段有名字、有优先级、有内容的提示词片段。系统提示词就是所有 Section 按 order 拼起来的结果。
完整 Sections 列表
当前代码库里注册的系统提示词 Section 如下,按 order 排序:
| order | name | 内容 |
|---|---|---|
| -100 | harness:identity |
DeepSeek Harness 身份(框架注册) |
| 0 | deployment:persona |
部署角色(框架注册占位,配置填内容) |
| 100 | tool:read |
Read 工具使用指导 |
| 101 | tool:write |
Write 工具使用指导 |
| 102 | tool:edit |
Edit 工具使用指导 |
| 103 | tool:glob |
Glob 文件搜索指导 |
| 104 | tool:grep |
Grep 内容搜索指导 |
| 105 | tool:bash / tool:pwsh |
Shell 命令执行指导(按平台二选一) |
| 106 | tool:jobs / tool:pty |
后台任务 / 终端会话指导 |
| 110 | tool:web_search |
网页搜索指导 |
| 111 | tool:web_fetch |
网页获取指导 |
| 112 | tool:lsp |
LSP 语言服务器指导 |
| 113 | tool:session-query |
会话查询指导 |
| 114 | tool:goal |
目标管理指导 |
| 115 | tool:cordis |
运行时自修改指导 |
| 116 | tool:ralph |
Ralph 迭代循环指导 |
| (其他) | tool:report 等 |
各工具插件的引导 |
框架本身只注册前两个:harness:identity 和 deployment:persona 占位。其余全是工具插件在 apply() 里动态注册的------注册即挂载,卸载即清理。
真实的提示词长这样
三段提示词对应三类:身份、性格、工具引导。工具引导举一个 tool:read 为例,其他工具同理:
bash
# 身份:harness:identity(-100,框架注册)
You are an AI agent powered by DeepSeek Harness.
# 性格:deployment:persona(0,配置填入,这里假设部署配置)
你是资深架构师,说话直接,先看代码再下结论。
# 工具引导:tool:read(100,tool-fs 注册)
Use the read tool --- not shell commands like cat --- to inspect text files.
Results include line numbers. Use offset and limit to continue reading large files.
工具引导 Section 干的事很单纯:每个工具一句话教模型怎么用。一百个工具就一百句话,全排在 100-116 这个区间里,按 order 依次排开。
身份从哪来?
identity 是模型读到的第一句话------它声明"这个 AI 是什么驱动的"。真实文本就是你在例子里看到的那句:
csharp
You are an AI agent powered by DeepSeek Harness.
它由框架注册:SystemPrompt 构造器里注册 harness:identity(order -100,system-prompt/index.ts:359)------不是插件放上来的,是框架自带的。内容固定,但可以配置关闭(includeHarnessIdentity,默认开启)。
身份回答的是"你是什么"------模型开口前先知道自己的出身。至于"你是什么性格",那是下一节 persona 的事。
persona 从哪来?
persona 不是写死在代码里的,而是配置项。在 cordis.patch.yml 里给 system-prompt 插件配一下:
yaml
- id: system-prompt
config:
persona: '你是资深架构师,说话直接,先看代码再下结论。'
实现上,SystemPrompt 构造器会注册一个名为 deployment:persona 的占位 Section(order: 0),文本就是 config.persona。没配就渲染为空,这个位置不出现。
想给某个特定 Agent 换性格?注册同名 Section deployment:persona 就行,子作用域覆盖全局------同一个 order 0 位置,谁后注册谁生效。所以 persona 有三层来源:默认空 → 部署配置(yaml)→ 插件覆盖。
工具引导从哪来?
工具引导 Section 不是框架注册的,是每个工具插件自己注册的 ------工具插件在 apply() 里注册工具的同时,顺手注册引导:
vbnet
// tool-fs 注册 read 工具时,顺手注册引导(tool-fs/src/read.ts)
ctx.systemPrompt.section({
name: 'tool:read',
order: 100,
text: 'Use the read tool --- not shell commands like cat --- to inspect text files.',
})
顺序按 order 排在 100-199 区间(当前是 100-116),和身份、性格的先后关系就是:身份(-100)→ 性格(0)→ 工具引导(100+) ------模型先知道"你是什么",再知道"你什么性格",最后知道"你能用什么工具"。
工具插件注册的其实是两样东西,别混淆:引导 Section (教模型怎么用,散文,进 system 文本)+ 工具 schema(定义接口,JSON,进 tools 参数)。一个管"怎么用",一个管"长什么样",同一个插件一起注册、一起生效。
拼装流程
拼装是三步流水线(assemble() → renderPrompt()):
- 收集:按作用域链合并所有 Section(子作用域同名覆盖全局),同时收集工具 schema 和变量。
- 排序:Section 按 order 升序;工具 schema 另按词法排序。
- 渲染:逐段处理------变量插值 → 丢弃空 Section → 空行连接成一段 system 文本。
scss
sections
.map(插值) // 1. {{variable}} 替换成运行时值
.filter(非空) // 2. 空的 Section 直接丢掉
.join(空行) // 3. 剩下的用空行连起来
这套机制换来了什么?
- 稳定前缀命中缓存:身份、性格、工具引导每次拼出的 system 文本一样,省 token 省延迟。
- 扩展不碰核心:加一个工具 = 工具插件注册一个 Section,框架一行不用改。
- 顺序自由:order 数字决定位置,插件想插哪就插哪。
系统提示词是插件们拼的拼图,框架只管拼装。
三、Agent Instructions:规则和记忆怎么加载
规则和记忆分两类进上下文:常驻规则 (AGENTS.md 等)每次请求都加载,按需技能(skill)用到才加载。先看常驻规则怎么被读到,再看 skill 什么时候加载、对它做了什么专门处理。
规则从哪来?AGENTS.md 怎么被读到
AI 凭什么守项目的规矩?因为每次请求前,agent-instructions 插件(packages/context/agent-instructions/)会把项目根的规则文件读进来。它在 agent/pre-step 事件里动手(index.ts 322 行),调用 loadBaselineInstructionSet(137 行)从磁盘读:
php
// index.ts 137 行附近:加载基线指令集
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
projectRoot,
signal,
}, fileSystem)
读哪些文件?默认是这两组(config.ts 12-13 行):
csharp
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
即基线 (项目根的 AGENTS.md / CLAUDE.md)+ 本地 (子目录的 AGENTS.local.md / CLAUDE.local.md)。候选列表可扩展------部署时往 instructionFileCandidates 里加文件(比如 MEMORY.md 记忆索引),它就会被当成指令加载。注意默认并不包含 MEMORY.md,它属于可扩展项,不是开箱即用的。
规则改了怎么办?
工具执行完,agent-instructions 会检查这个工具是否触及了规则文件(read/write/edit 打开了 AGENTS.md?),触及了就标记,下一个 pre-step 重读(index.ts 350 行):
dart
// index.ts 350 行附近:工具结果触发重新加载
ctx.on('tools/result', (exec, result) => {
const touches = executionTouches.get(exec.token) ?? []
// ... 从执行记录里找它读/写过的文件路径
// 触及指令文件的 → 队列化投影,下一个 pre-step 重读
})
所以改规则不用重启------改完 AGENTS.md,下一步就生效。
规则怎么进消息?
pre-step 监听器在 waterfall 里调用 compose() 生成指令消息,然后折叠进 claimed 消息之后(源码注释原话:"Fold the context right after the claimed batch")------即指令作为 User 消息,紧跟用户输入。
什么时候加载?
常驻规则是"每次都要遵守的";skill 是"偶尔要用到的能力",按需加载:
- 目录常驻 :skill-filesystem 从项目、自定义、用户根目录发现技能,解析 YAML frontmatter,模型在上下文中只看到技能目录(catalog:名字 + 摘要);
- 正文按需 :模型决定用某个技能时,调用
skill工具,才把该技能的完整 SKILL.md 正文加载进上下文(正文通过ctx.fs按需读盘)。
对 skill 做了什么专门处理?
skill 不是简单的"读文件塞进去",有三层专门处理:
- frontmatter 解析 (skill-filesystem/index.ts 909 行
parseFrontmatter):SKILL.md 开头有 YAML frontmatter(name/description),解析出目录项(data)+ 正文(body)------模型看到的是解析后的目录(名字 + 摘要),不是整篇 skill,据此决定要不要调用; - 目录与正文分离 :目录(catalog)是会话上下文的一部分,正文(SKILL.md body,
body.trim()清理后)在调用时才读盘------一百个技能只占一百行摘要,不占整篇正文; - 监听更新 (
watch配置):技能目录变化(增删改)即时反映到目录,不用重启。
这套机制换来了什么?
常驻规则 + 按需技能的组合,换来的是执行效率:规则自动生效(AGENTS.md 每次请求自动加载,项目改规则 AI 下一步就跟上)、技能按需加载(一百个技能只占一百行摘要,只有用时才读正文------省 token、少干扰)、改动即时生效(工具触及规则文件自动重载、skill 目录 watch 监听,都不用重启)。一句话:常驻规则管"每次要守的规矩"(小、稳定、自动加载),按需技能管"偶尔要用的能力"(大、可选、省 token) ------各取所需,AI 在有限的上下文里只装最该装的。
四、动态上下文(Context):运行时快照怎么注入
动态上下文(源码里叫 dynamic context,system-prompt/index.ts 顶部注释)提供运行时状态快照------现在几点、终端什么状态、正在委托哪个子代理、沙箱允许什么、用户批没批准。它和 Sections 一样是内容提供者,区别是内容会变:Sections 管"你是谁"(静态、前缀、命中缓存),动态上下文管"你现在在哪"(动态、变化才注入)。注册单元是 PromptContext。下面看它谁提供的 、 "变化才注入"怎么实现的。
动态上下文从哪来?两种接入方式
动态上下文不是框架内置写死的,是各个 context 插件提供的------读代码时想知道"快照是谁提供的",就看它用哪种方式接入:
方式一:注册进 SystemPrompt。 插件在 apply() 里调 systemPrompt.context()(注册即挂载、卸载即清理),注册一个 PromptContext(name / order / text,text 可以是每次拼装时求值的函数)。当前代码库里这类有三处:
| name | 谁注册 | 内容 |
|---|---|---|
subagent:delegation |
subagent(child-agent.ts 170 行) | 子代理委托上下文 |
sandbox-policy |
sandbox-policy(113 行) | 沙箱策略状态 |
user-approval |
user-approval(205 行) | 用户审批状态 |
方式二:自己监听 agent/pre-step。 time-context(当前时间)、tmux-context(终端状态)、agent-instructions(规则)不走注册表,而是监听 agent/pre-step 事件(time-context 在 index.ts 170 行,{ prepend: true } 排在所有监听器最前),在 waterfall 里自己生成一条 User 消息、追加到 messages 尾部------比如 time-context 用自己的 refreshIntervalMs 节流,没到间隔就不重刷。
两种方式的区别一句话:方式一交给 SystemPrompt 统一收集渲染(assemble → project),方式二自己生成、自己追加 。读代码时:方式一的插件找 systemPrompt.context(),方式二的找 ctx.on('agent/pre-step')。
真实的快照长这样
动态上下文提供的快照,有的注册时就是固定文本,有的每次拼装时现算(PromptContext.text 可以是函数)。两个真实例子:
sql
# subagent:delegation(子代理委托,固定文本常量,child-agent.ts 135 行)
You are a delegated subagent: your permission scope was fixed when you were started
and cannot be widened from inside this session --- operations that require approval
are rejected automatically. When the task needs access beyond that scope, do not
retry the denied operation; state the limitation in your reply so the delegating
agent can handle it.
# time-context(时间,每次拼装现算,index.ts 110 行 renderText 的格式)
Time sampled while preparing turn 3, step 1: 2026-08-15 14:30:00 GMT+8
Elapsed since the preceding model-visible message: 2m 13s.
前者是常量(子代理的权限声明,每个子代理一样),后者是函数现算(时间每次不同)。sandbox-policy、user-approval 也是函数式的------读代码时它们的 text: (context) => ... 就是每次求值的入口。
"变化才注入"是怎么实现的?
注册方式的核心在 runtimeContext.project()(runtime-context.ts 64 行)------它记住上次注入的快照文本,每次渲染完比较:
kotlin
project(current: string, sections) {
// 空快照 → 打"已清除"标记,而不是静默消失
const snapshot = current.length === 0 ? CLEARED : current
// 和上次一样 → 不注入(返回 undefined)
if (this.retained?.text === snapshot) return
// 不一样 → 创建一条带来源的快照消息注入
return { content: [{ type: 'text', text: snapshot }], ... }
}
三个分支对应三种情况:
- 内容没变 → 不注入,省 token(这是"变化才注入"的实现);
- 内容变成空 → 注入
CLEARED标记("Current runtime context: none. Earlier runtime-context snapshots no longer apply.")------告诉模型"之前的快照不再适用",而不是默默消失; - 内容变了 → 注入新快照,消息带来源信息(哪几个 context 贡献的)。
自监听的方式没有这套文本比较------它用自己的节流(time-context 的 refreshIntervalMs),没到间隔不重刷。两种方式殊途同归:内容不变就不占上下文。
五、拼接流程:三层内容怎么汇成一个请求
在哪个环节拼?preStep 四步
三层内容在 preStep(agent.ts 225-243)汇合------每次调模型前的准备,四步:
arduino
private async preStep(target: InboxTarget, position: { turn, step }): Promise<PreparedStep> {
const claimed = this.inbox.claim(target, position.turn) // ① 取用户输入
// ② 组装 Sections(系统提示词)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
// ③ 渲染动态上下文(快照)并注入
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
// ④ waterfall:agent-instructions 在这里把 AGENTS.md 等折叠进消息
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
() => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context] }),
)
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
四步各干一件事:
- 取用户输入------claim 走 Inbox 里排队的消息;
- 组装 Sections ------
assemble()收集所有 Section、排序;之后在 step 里renderPrompt()渲染成 system 文本(静态部分这里只"收集排序",真正渲染在 step); - 渲染并注入动态上下文 ------
renderContextSections()渲染快照 →runtimeContext.project()比较并注入(变化才注入,见第四节); - waterfall 折叠 Agent Instructions ------
agent/pre-step是 waterfall 事件,agent-instructions监听它(322 行),把 AGENTS.md 等指令折叠进消息列表。
各部分拼到哪?system 字段 + messages 数组
拼接的本质:Sections 渲染成 system 字段的一段文本,其余内容按顺序塞进 messages 数组 (Message[],每段内容是一个元素):

位置规则:用户输入在最前(messages[0]),指令紧跟其后(折叠在 claimed 之后,messages[1]),动态快照随后(messages[2]),历史消息垫底(messages[3..N])------每一步都是一个数组元素,模型按顺序读。
拼完的请求长什么样
先看顺序:
ini
┌─ system 字段(静态前缀,缓存命中)─────────────┐
│ 1. harness:identity (-100) │
│ 2. deployment:persona (0) │
│ 3. tool:read (100) │
│ 4. ...(其他工具引导,100-116) │
└──────────────────────────────────────────────┘
┌─ messages 数组(按顺序)──────────────────────┐
│ [0] 用户输入 │
│ [1] Agent Instructions:AGENTS.md / CLAUDE.md │ ← 规则,pre-step 折叠进来
│ [2] 动态上下文快照 │ ← 委托 / 沙箱 / 审批
│ [3..N] 历史消息 │
└──────────────────────────────────────────────┘
再看数据结构------发给 LLM 的请求是 system / messages / tools 三个字段 (system 和 tools 是真实文本和真实 schema,messages 内容为示意;system 实际是全部注册 Section 拼的,这里示意三个):
swift
{
"provider": "deepseek",
"model": "deepseek-chat",
"system": "You are an AI agent powered by DeepSeek Harness.\n\n你是资深架构师,说话直接。\n\nUse the read tool --- not shell commands like cat --- to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.",
"messages": [
{ "role": "user", "content": "列出当前目录文件" },
{ "role": "user", "content": "(AGENTS.md 指令,agent-instructions 折叠进来)" },
{ "role": "user", "content": "(动态快照:委托状态 / 沙箱策略 / 审批状态)" },
{ "role": "assistant", "content": "我来查看。", "tool_calls": [{ "id": "call_1", "type": "function", "function": { "name": "bash", "arguments": "{"command":"ls"}" } }] },
{ "role": "tool", "tool_call_id": "call_1", "content": "src/ docs/ README.md" }
],
"tools": [
{ "type": "function", "function": { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", "parameters": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to read, resolved by the filesystem backend." }, "offset": { "type": "number", "description": "1-based first line to return. Defaults to 1." }, "limit": { "type": "number", "description": "Maximum number of lines to return." } }, "required": ["file_path"] } } },
{ "type": "function", "function": { "name": "bash", "description": "Execute a bash command.", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "The bash command to execute." }, "description": { "type": "string", "description": "Clear, concise description of what this command does." }, "timeoutMs": { "type": "number" }, "workdir": { "type": "string" } }, "required": ["command", "description"] } } }
]
}
三个字段的分工:system 是拼装好的 Sections 文本(静态前缀);messages 是数组------用户输入、指令、快照、历史按序排列,模型与工具的往返也都在这里(role: user / assistant / tool,tool_calls 和 tool_call_id 配对);tools 是工具 schema 列表(结构化定义,模型据此生成合法的 tool_calls)。
顺序就是效率:静态(system)在前命中缓存、动态(messages)在后不碰前缀、工具 schema 结构化驱动合法 tool_calls------每一层的位置都有目的,AI 在正确的顺序里读到正确的内容。
总结
回到开头:模型每次请求看到的上下文,是三层拼出来的。看完你会明白,每一层都有自己的位置和理由:
- Sections(System) ------稳定前缀,管"你是谁、能做什么",命中缓存;
- Agent Instructions(User) ------规则和记忆,动态加载、自动更新,不污染前缀;
- Contexts(User) ------运行时快照,变化才注入。
三层都在 preStep(每次调模型前)汇合:assemble Sections → 注入 Contexts → waterfall 折叠指令------然后一起进模型请求。
值得带走的是一个判断:什么该进 System、什么该进 User? 稳定的、要命中缓存的放 System(静态前缀);会变的、跟着现场走的放 User(动态尾部)。这不是 DeepSeek Harness 的细节,是任何 Agent 系统拼上下文时都要做的取舍------把变化的内容塞进稳定前缀,缓存就废了。
开头那句话,现在可以回收了:模型看到的上下文是三层拼出来的,不是哪一层单独写出来的------每一层由谁贡献、放哪、怎么更新,都写在代码里。
系列文章导航
| 编号 | 文章 | 对应模块 |
|---|---|---|
| 000 | 5 分钟看懂 DeepSeek Harness | 全景图 |
| 001 | 动力引擎:Agent Loop 是怎么转起来的 | AGENT LOOP |
| 002 | 上下文拼接:系统提示词与动态内容(本页) | SYSTEM PROMPT |
| 003 | LLM 适配层:从 DeepSeek 到任意模型 | LLM |
| 004 | 工具系统设计:三段 waterfall 管道 | TOOLS |
| 005 | 追加式事件日志:Session 设计 | SESSION |
| 006 | 上下文压缩:长对话管理 | COMPACTION |
| 007 | 能力接缝模式:定义/实现/使用分离 | SHELL + 全部 Seam |
| 008 | Cordis 插件体系 | CORDIS |
| 009 | Subagent:委托与隔离(规划中) | SUBAGENT |
| 010 | Workflow:模型驱动的多 Agent 协作(规划中) | WORKFLOW |
| 011 | Guard + Permission + Sandbox(规划中) | GUARD / INTERACTION / SANDBOX |
| 012 | Plan + Preset + Context + Todo(规划中) | PLAN / PRESET / CONTEXT / TODO |
| 013 | Hooks:Claude Code / Codex hook 桥(规划中) | HOOKS |
常见问题 FAQ
Q: Sections、Agent Instructions、Contexts 三层的区别是什么?
A: 按角色和内容分:Sections 是 System 角色(身份、性格、工具引导),静态、按 order 排序、命中缓存;Agent Instructions 是 User 角色(AGENTS.md / CLAUDE.md 等规则记忆),动态加载、工具改动自动重载;Contexts 是 User 角色(委托、沙箱、审批等运行时快照),变化才注入。System 管"你是谁、能做什么",User 管"项目规则、当前状态"。
Q: Agent Instructions 什么时候加载、什么时候更新?
A: 加载在 agent/pre-step 事件(agent-instructions 的 322 行监听),每次 Step 前从磁盘读 AGENTS.md / CLAUDE.md 等;更新在 tools/result 事件(350 行)------工具执行完检查它是否触及了指令文件,触及就标记,下一个 pre-step 重读。改规则文件不用重启。
Q: 为什么规则和记忆放 User 消息而不是 System?
A: 因为 System 是稳定前缀,要命中 LLM 缓存------规则文件会变(改 AGENTS.md 就要重读),塞进 System 会让前缀变化、缓存全部失效。放 User 消息尾部,变化不影响前缀。
Q: 拼接发生在哪个环节?
A: preStep(agent.ts 225-243),每次调模型前:① claim 用户输入 ② assemble Sections ③ renderContextSections + project 注入 Contexts ④ waterfall 让 agent-instructions 折叠指令。静态渲染成 system 在 step 里做(renderPrompt)。
Q: MEMORY.md 是默认加载的吗?
A: 不是。agent-instructions 的默认候选是 AGENTS.md / CLAUDE.md(基线)+ AGENTS.local.md / CLAUDE.local.md(本地)。MEMORY.md 需要部署时往 instructionFileCandidates 配置里加才会被加载。
Q: 工具是怎么拼进上下文的?工具太多怎么办?
A: 工具进上下文是两条路:工具引导 Section (order 100-116,散文)教模型"怎么用",拼进 system 文本;工具 schema (结构化定义)单独进请求的 tools 参数(agent.ts 490 行 request.tools),模型看到的是 API 级定义列表。
工具太多会不会挤爆上下文?DH 没有做运行时裁剪 ------没有工具数量上限、没有 token 预算裁剪、description 也不截断:如果 1 万个工具都在同一个 Agent 的作用域里可见,它就把 1 万份 schema 全拼进请求。它做的是结构性控制:schema 精简(每份只保留 name/description/parameters 三字段)、作用域隔离(子 Agent 只看到自己作用域的工具 + 全局可见的,单个 Agent 实际可见是几十个量级)、部署配置(preset 禁用限制工具)、缓存(词法排序稳定命中)。工具数量的最终控制靠部署设计,不靠框架自动裁剪。
Q: 工具列表顺序为什么固定?
A: 为了 LLM 缓存。prompt caching 按请求前缀匹配,工具引导(Section 100-116)是前缀的一部分------顺序稳定才能命中缓存。所以 order 是写死的数字、工具 schema 按词法排序,而不是谁先注册谁在前。
参考链接
- DeepSeek Harness 仓库:github.com/deepseek-ai...