第二部分 · 核心循环
第 6 章:提示词、上下文与压缩
系统提示词是分节注册表,运行时上下文是注入的消息,压缩是可换后端的 capability
提示词也是插件
在多数 agent 框架里,系统提示词是一段(或几段拼接的)大字符串,由核心作者维护。dsh 把它做成了分节注册表:任何插件都可以注册一节系统提示词,按 order 排序拼接,注册即 effect、卸载即消失。packages/core/system-prompt/src/index.ts 的 PromptSection:
/** One contributed section of the system prompt (registry input). */
export interface PromptSection {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
readonly name: string
/**
* Sections are concatenated in ascending order. Convention: `-100` is the
* harness identity, `0` the deployment persona, tool guidance uses 100–199;
* other negative orders also render before the persona.
*/
readonly order: number
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
*/
readonly text: string | ((context: AssembleContext) => string)
}
四个注册面各管一类输入:
| API | 内容 | 何时进入模型请求 |
|---|---|---|
systemPrompt.section() | 系统提示词分节(按 order 排序) | 渲染进 request.system 字符串 |
systemPrompt.tools() | 工具 schema 提供者 | 经 GenerateOptions.tools 单独发送(不进字符串) |
systemPrompt.context() | 动态模型上下文(如沙箱策略、权限状态) | 投影为 user-role 快照消息,追加进下一步(见下) |
systemPrompt.variable() | {{variable}} 插值源 | 渲染时插值进分节文本 |
装配(assemble())产出的是未插值的 PromptAssembly(sections/contexts/tools/variables 四元组),直到 renderPrompt() 才同步渲染成字符串。分节与插值两阶段分离,让监听器可以在瀑布里改写未渲染的装配、而不是改字符串。工具 schema 走 GenerateOptions.tools 独立通道——这也解释了为什么 ToolSchema 声明在 dsh-llm 而不是 dsh-tools:schema 是模型请求的一部分,不是工具系统的私有物。
分节的顺序约定写进了类型注释:-100 是 harness 身份、0 是部署人设(PERSONA_SECTION/PERSONA_ORDER 导出,preset 通过替换同名节来换人设)、100–199 是工具指引。每个工具都注册一节自己的「使用指引」——第 5 章 Code Mode 的 collapse 段就利用 order 排在这些指引之前,先声明「只能直接调 run_code」再让各工具自吹自擂。默认组合里实际存在的分节(grep .section({ 可得):
| order | section | 来源 |
|---|---|---|
-100 | harness:identity | 内建:「You are an AI agent powered by DeepSeek Harness.」 |
0 | deployment:persona | 部署人设(可被 scope 遮蔽/替换) |
100–199 | tool:bash、tool:read/write/edit、tool:lsp(112)、tool:cordis(115)、tool:web-search/fetch、tool:subagent、tool:workflow、tool:goal、tool:jobs… | 各工具/能力插件注册自己的使用指引 |
| — | plan:policy、sandbox:policy 等 | plan mode 与沙箱策略的约束段 |
运行时上下文:注入的消息
「动态上下文」在 dsh 里不是一个概念上的东西,而是一类具体的消息:systemPrompt.context() 注册的 PromptContext 在每次装配时求值,投影成 user-role 快照,追加进 pre-step 的 enter 消息。链条是(第 3 章的 preStep()):
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)
RuntimeContextProjection(agent-loop/src/runtime-context.ts)是这中间的守门员:它追踪最近保留的运行时上下文快照,project() 只在内容变化时产出候选消息,清空时用固定的 CLEARED 标记文本("Current runtime context: none. Earlier runtime-context snapshots no longer apply.")——避免每次 step 都塞一份相同的重复上下文。它是「agent 当前处境」的动态快照:沙箱策略(order 110)、权限状态、工具指引……都以 user-role 消息的形式进入模型视野,跟普通用户消息同一条投影路径,因此也遵守「模型可见 ⟺ 已记录」。
「动态上下文」不止 systemPrompt.context() 一条来源——packages/context/ 下是四个独立的 request-context 插件,全部产出模型可见的 user 消息、全部经 agent/pre-step 或 inbox 注入:
| 插件 | 默认 | 内容 |
|---|---|---|
agent-instructions | 开 | 加载 AGENTS.md 兼容文件为 <system-reminder> 文本的 user 消息,文件 touch 后增量更新 |
time-context | 关 | 每步注入时间快照(采样时刻、时区、距上一条模型可见消息的经过时长),按 refreshIntervalMs 去重 |
session-reference | — | 其他 session 的有界快照(ctx.sessionReferenceResolver) |
tmux-context | — | tmux 位置上下文 |
注意「contextWindow / token 预算 / 超长截断」不在 context 包:容量信息在 llm(resolveModelInfo().context.contextWindow)、压力度量在 token-meter(contextPressure.pressureTokens 区分未缓存输入与缓存读写)、超长处理在 compaction 的溢出恢复路径——三个问题分属三个接缝。
compaction:遗忘也是一种能力
上下文总会涨过 contextWindow。dsh 把「压缩」也做成了 capability 三件套(第 7 章会正式定义这个词):
| 角色 | 包 | 内容 |
|---|---|---|
| Service Definition | compaction/compaction | abstract class CompactionEngine:compactIfNeeded(自动)、compactNow(手动)、compactRegion(强制区间) |
| Service Provider | compaction/compaction-basic | BasicCompactionEngine:唯一钩子 summarize()(用模型把区间折叠成摘要) |
| Consumer | compaction/command-compact + agent-loop 自动路径 | /compact 命令(手动);压力/溢出自动触发 |
自动触发挂在两个精确的位置(compaction-basic/src/index.ts):压力路径是 agent/pre-step 的串行监听器——tokenMeter.measure(session) 度量压力,llm.resolveModelInfo() 取 contextWindow,thresholdTokens = contextWindow × 0.8 超阈值才压;溢出路径是 agent/request-error 监听器——failure.code === CONTEXT_WINDOW_EXCEEDED 时忽略阈值强制做一次平衡缩减,成功后返回 {kind:'retry'} 让循环重试同一请求(maxOverflowRetries 封顶)。
压缩的写回是一笔append-only 事务,不是就地改写:
compaction/start ← 事务锁(直到一次 compaction/end 尝试)
compaction/summary ← 摘要事件:{summary, shadowedRange, shadowedSeqs,
shadowedTokenCount, provider, model, usage…}
user/message { surfaceOp: {op:'replace', start, end} } ← 替换选中的表面区间
source: compactCheckpointSource(CompactionId)
sourceEventSeqs: [start.seq, summary.seq, …]
compaction/end ← 提交/回滚标记
被替换的区间必须满足两个约束:tool-call 配对平衡(toolPairingBalancedBefore/After,区间边界不能切开一个工具调用与其结果)与head 锚定 + 尾部保留(selectCompactableRange 从尾部累加 tokens 到保留预算,再向前回退到配对平衡点——压缩的是历史头部,最近的工作留在表面)。替换节点用 compactCheckpointSource 携带事务身份,任何消费者都能独立识别与关联——不依赖后端实现。由于一切都在日志里,压缩本身可审计、可回放;「被压缩掉的对话」以一条摘要节点的形式永远留在日志里,而不是被删除。
摘要调用有一个被刻意设计的性质——KV-cache 友好(summarizer.ts):它复用对话自身的 system + tools + messages 前缀,把固定的 COMPACTION_INSTRUCTION(Primary Request / Key Technical Concepts / … / Next Step 八节模板)作为最后一条 user 消息。这让摘要调用成为上一次模型请求的真前缀——provider 的 warm prefix cache 被复用而不是失效。同样地,快照按文本去重、token 预算区分未缓存输入与缓存读写,都是同一个原则:压缩不是破坏缓存的惩罚,而是缓存友好的维护操作。
在《Pi Agent 源码解析》第 7 章与《Prime Agent 源码解析》第 12 章里,压缩是核心内置的一个函数:选切点、生成摘要、替换上下文数组。在 dsh 里,压缩是可换后端的 capability——触发策略、保留策略、摘要方式全部归实现者所有,消费者(/compact 命令与自动路径)只看 CompactionEngine 接口。更重要的是压缩产物的身份:前作的压缩是「把数组换掉」,dsh 的压缩是「往日志里追加一笔带事务标记的替换事件」——前者是一次状态变更,后者是一次可审计的记账。
BlockAssembler:chunk 流的收口
提示词与上下文装配好之后,模型请求发出,chunk 流回来。第 9 章会详述 LLM 接缝,这里先看装配链上最后一个环节:BlockAssembler(packages/llm/llm/src/assembler.ts)如何把 StreamChunk 流聚成消息 block。第 3 章看到循环里每收一个 chunk 先落日志再 assembler.push(chunk);blocks() 在 finish.kind === 'max-tokens' 时会丢弃未执行的 tool-call block——安全截断:模型还没把工具参数说完就被截断,绝不能把半个调用发给执行器。这就是为什么「一次模型请求」在日志里被拆成 chunk 级事件 + message 级事件两层(第 4 章):assistant/message 的 sourceEventSeqs 引用的正是组成它的 chunk seq。
实践应用
- 提示词分节注册、两阶段渲染:分节注册让每个能力自持一段提示词(随插件装卸),
assemble与renderPrompt分离让瀑布可以改写未渲染的装配——比「拼接字符串」的扩展方式干净一个量级。 - 动态上下文是消息不是魔法:把运行时状态(沙箱策略、权限)做成 user-role 快照消息走同一条投影路径,天然满足「模型可见 ⟺ 已记录」,也天然可重放。
- 压缩是记账不是删改:用 append-only 事务 + 表面替换事件做压缩,产物可审计、可回放、可关联;「遗忘」不再破坏日志的完整性。
- 切点必须配对平衡:区间边界用 tool-call 配对平衡约束——压缩任何会话历史前,先保证不切开工具调用与其结果。
总结
这一章拆完了提示词、上下文与压缩:分节注册的 system-prompt、以消息形式注入的运行时上下文、以及作为 capability 的 compaction——全部延续着同一个主题:模型看到的一切都是可注册、可重放、可替换的。第二部分(核心循环)到此闭环:循环、日志、工具、提示词。下一部分我们把镜头拉远到系统外围——第三部分的第一章讲 dsh 组织「可替换能力」的方式:capability seam 三件套,以及 fs/subprocess/shell/terminal 四个实例如何把「换后端」变成配置。