第二部分 · 核心循环

第 6 章:提示词、上下文与压缩

系统提示词是分节注册表,运行时上下文是注入的消息,压缩是可换后端的 capability

提示词也是插件

在多数 agent 框架里,系统提示词是一段(或几段拼接的)大字符串,由核心作者维护。dsh 把它做成了分节注册表:任何插件都可以注册一节系统提示词,按 order 排序拼接,注册即 effect、卸载即消失。packages/core/system-prompt/src/index.tsPromptSection

/** 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({ 可得):

ordersection来源
-100harness:identity内建:「You are an AI agent powered by DeepSeek Harness.」
0deployment:persona部署人设(可被 scope 遮蔽/替换)
100–199tool:bashtool:read/write/edittool:lsp(112)、tool:cordis(115)、tool:web-search/fetchtool:subagenttool:workflowtool:goaltool:jobs各工具/能力插件注册自己的使用指引
plan:policysandbox:policyplan 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],
      }),
    )

RuntimeContextProjectionagent-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-contexttmux 位置上下文

注意「contextWindow / token 预算 / 超长截断」不在 context 包:容量信息在 llmresolveModelInfo().context.contextWindow)、压力度量在 token-metercontextPressure.pressureTokens 区分未缓存输入与缓存读写)、超长处理在 compaction 的溢出恢复路径——三个问题分属三个接缝。

compaction:遗忘也是一种能力

上下文总会涨过 contextWindow。dsh 把「压缩」也做成了 capability 三件套(第 7 章会正式定义这个词):

角色内容
Service Definitioncompaction/compactionabstract class CompactionEnginecompactIfNeeded(自动)、compactNow(手动)、compactRegion(强制区间)
Service Providercompaction/compaction-basicBasicCompactionEngine:唯一钩子 summarize()(用模型把区间折叠成摘要)
Consumercompaction/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 接缝,这里先看装配链上最后一个环节:BlockAssemblerpackages/llm/llm/src/assembler.ts)如何把 StreamChunk 流聚成消息 block。第 3 章看到循环里每收一个 chunk 先落日志再 assembler.push(chunk)blocks()finish.kind === 'max-tokens' 时会丢弃未执行的 tool-call block——安全截断:模型还没把工具参数说完就被截断,绝不能把半个调用发给执行器。这就是为什么「一次模型请求」在日志里被拆成 chunk 级事件 + message 级事件两层(第 4 章):assistant/messagesourceEventSeqs 引用的正是组成它的 chunk seq。

实践应用

  1. 提示词分节注册、两阶段渲染:分节注册让每个能力自持一段提示词(随插件装卸),assemblerenderPrompt 分离让瀑布可以改写未渲染的装配——比「拼接字符串」的扩展方式干净一个量级。
  2. 动态上下文是消息不是魔法:把运行时状态(沙箱策略、权限)做成 user-role 快照消息走同一条投影路径,天然满足「模型可见 ⟺ 已记录」,也天然可重放。
  3. 压缩是记账不是删改:用 append-only 事务 + 表面替换事件做压缩,产物可审计、可回放、可关联;「遗忘」不再破坏日志的完整性。
  4. 切点必须配对平衡:区间边界用 tool-call 配对平衡约束——压缩任何会话历史前,先保证不切开工具调用与其结果。

总结

这一章拆完了提示词、上下文与压缩:分节注册的 system-prompt、以消息形式注入的运行时上下文、以及作为 capability 的 compaction——全部延续着同一个主题:模型看到的一切都是可注册、可重放、可替换的。第二部分(核心循环)到此闭环:循环、日志、工具、提示词。下一部分我们把镜头拉远到系统外围——第三部分的第一章讲 dsh 组织「可替换能力」的方式:capability seam 三件套,以及 fs/subprocess/shell/terminal 四个实例如何把「换后端」变成配置。