上下文系统
一句话版:模型看到什么 = agent-loop 在请求前把系统提示词 + 工具 schema + 派生会话历史装配成一段受控上下文;超长时走**压缩(compact)**而不是截断。
context/包只提供额外的上下文注入源,不是装配层。
这是继 Agent 主循环 之后的第二篇核心。你理解"每次模型请求到底发了什么、为什么有时慢/费 token",就靠这一篇。
一、谁在装配上下文(纠正常见误解)
很多人以为"上下文是 context 包组装的":不对。真实分工:
| 层 | 职责 | 归属 |
|---|---|---|
| 装配主体 | 每次 step 把系统提示词 + 工具 schema + 派生消息组装成请求 | agent-loop(assembleContextFor / buildRequest + agent/request 装配瀑布) |
| 系统提示词注册 | 插件贡献有序的提示词段、工具 schema、命名变量,并渲染 | core/system-prompt(ctx.systemPrompt) |
| 工具清单呈现 | 决定以 native/code/both 哪种方式给模型 | core/tools + core/agent-tool-presentation |
| 会话历史 | 事件溯源日志 → surface 消息投影 | core/session |
| 额外注入源 | 向请求注入额外模型可见上下文(不定义工具) | context/* |
| 压缩 | 超限时折叠历史 | compaction/* |
一句话记住:装配是 agent-loop 的事,context/* 只是"可选的额外喂给模型的话",system-prompt 是提示词段的注册中心。
二、系统提示词:注册中心 ctx.systemPrompt
@deepseek-ai/dsh-system-prompt 是系统提示词组装注册表。插件贡献有序的段、工具 schema、命名变量;agent-loop 每步组装一次,渲染成完整模型提示。
6 个核心入口
| 入口 | 作用 |
|---|---|
ctx.systemPrompt.section({ name, order, text, complete? }) | 贡献一段提示词,按 order 升序排列 |
ctx.systemPrompt.context(provider) | 贡献有序动态上下文;每次 eligible assembly 求值并形成带来源的 user-role 快照 |
ctx.systemPrompt.suppressRuntimeContext() | 在当前 scope 抑制全部动态上下文;dispose 后恢复 |
ctx.systemPrompt.tools(provider) | 贡献工具 schema(每次 assembly 用当时的 context 求值) |
ctx.systemPrompt.variable(name, provider) | 贡献提示词变量,段文本用 {{name}} 引用 |
ctx.systemPrompt.assemble(context?) | 做一次完整装配(global 层 + scope 层,过 waterfall) |
order:段怎么排位
段按 order 升序拼接。源码约定的 order 带:
-100 harness identity(固定"You are an AI agent powered by DeepSeek Harness.")
0 deployment persona(仅一个,来自 config)
100–199 工具指引(tool:bash / tool:read …)
includeHarnessIdentity: true(默认)注入固定的You are an AI agent powered by DeepSeek Harness.includeRuntimeContext: true(默认)求值动态 context provider;设为 false 时丢弃 provider 与 waterfall 追加的上下文,但底层沙箱、审批、委派等服务继续执行deployment:persona是 config 里的 ONE 段(全局部署人格);一个 agent-scoped 贡献可以遮蔽它- 一个
complete: true的段会在装配瀑布后变成"唯一提示词段";同层多于一个 effective complete → 装配失败
scope 分层
和工具一样,systemPrompt 按调用上下文的作用域分层:
- 在
agent.ctx里注册的段/变量/工具 → 只对该 agent 生效,并遮蔽同名全局项 - 全局注册 → 全体可见
这让"给特定 agent 一套人设/工具"成为可能(见 Agent 预设与 Persona)。
变量渲染:严格,不是橡皮筋
renderPrompt(assembly) 做严格的 {{variable}} 插值,然后丢弃空段、用空行拼接。fail-loud:
- 未注册的变量引用、已注册但无值的引用、残缺
{{…}}组 → 抛错(宁可失败也不发畸形提示) - 孤立
{{(后面没有}})则原样通过 - 替换后的值不会被二次扫描
严格是为了稳定:系统提示词逐字节一致是 KV cache 复用的前提(见下)。
装配会过 system-prompt/assemble waterfall:监听器可以协作地改动/替换装配(受 scope 过滤),之后才应用 complete 段约束和 runtime-context suppressor。动态上下文与系统提示词段是两条独立通道,只在有内容时成为带来源的 user-role 快照。
想给 agent 加"当前日期"这类动态事实?注册一个
variableprovider,在任何段里用{{date}}引用:比把硬编码拼进 persona 干净得多。agent loop 已注册provider、model、cwd三个变量。
三、工具 schema 进装配
工具的 schema 是装配的一部分:README 原话是"what the model is told it can do is one coherent thing":即使 adapter 在线上把 schema 作为独立 wire 字段传输,在装配层面它和提示词是一个整体。
ToolRuntime会自动把自己注册为 tool provider,把可见工具 schema 交进systemPrompttoolOrderconfig 可显式排工具呈现顺序(列出name,用'<unlisted-tools>'占位余下),错误配置会 fail-loud- 呈现方式(native / code / both)由
ctx.tools.presentAs+core/agent-tool-presentation决定,见 工具执行
四、请求头(request header):一次请求的"名片"
每次模型请求都有一个请求头,记录这次请求的路由与装配:
{ config: { provider, model, reasoningEffort, maxTokens },
system: <组装后的系统提示词>,
tools: <呈现给模型的工具清单> }
request/header 有几个值得懂的语义:
reason:initial/resume/change。header 与上一次逐字节相同时不重发change事件:这是不失效 prefix-cache 的开关- 压缩摘要调用复用会话当前请求头(对齐 KV cache);但"辅助调用(子 agent 等)复用同头"无源码依据:子 agent 有独立 header
- 配套
request/context事件,携带 provider/model/contextWindow 路由元数据 request/header记录在会话日志里:你随时可以在 JSONL 里看某条消息到底用哪个模型、系统提示词拼成了什么样
这就是 Agent 主循环 里"adapter-default 标记"落地的地方:请求头记录哪些字段来自 adapter,下一 waterfall 去掉它们让当前 route 重新物化默认,保证 HMR 不串味。
五、Context 注入源:context/*
context/ 组的产品插件往请求里加模型可见的额外上下文,但不定义工具:
| 包 | ctx key | 角色 |
|---|---|---|
session-reference | ctx.sessionReferenceResolver | 其它会话的有界快照 |
time-context | : | 当前时间 / 经过时间 |
tmux-context | : | tmux 位置上下文 |
agent-instructions | : | 工作区指令上下文 |
agent-instructions 在默认 demo bundle 里;其余 opt-in。它们是"可选给模型喂的料",不参与系统提示词的 order 装配。
六、token 计量(token-meter):压缩的判断依据
compact 怎么知道"该压了、压多少"?靠 @deepseek-ai/dsh-token-meter 挂载的 ctx.tokenMeter 服务(默认挂载于 base bundle)。它是一个**可重放(replay-aware)**的计量单例:每次测量都基于持久化日志的最新消费位点(consumed-log revision),所以压缩和其它对压力敏感的插件能共享同一套账目,而不必依赖 CompactionEngine。
估算是"固定启发式",不是精确 tokenizer
计量器没有任何配置项,故意只用一条固定启发式:每 4 个字符 ≈ 1 token,再加上 role / block / 请求信封字段的结构开销。任何配置 key 都会被拒绝——模型容量属于拥有精确 provider/model 路由的 adapter,通过 ctx.llm.resolveModelInfo().context 拿。
两个入口
| 操作 | 返回 | 说明 |
|---|---|---|
ctx.tokenMeter.measure(session, requestHeader?) | { totalTokens, surfaceTokens, nodes[], … } | 在一个消费位点上同步一次,返回一份深不可变的分离快照 |
ctx.tokenMeter.estimateMessage(message) | 单条消息的估算 | 用同一条固定启发式为一条消息计价 |
totalTokens= 请求 + 响应的压力;surfaceTokens= 只算表面的启发式总量,恰好等于nodes[].tokens之和。requestHeader覆盖只影响压力字段;表面字段仍描述当前会话。- 每次调用都克隆定位节点,所以测量复杂度是 O(surface)。
provider usage 的复用规则
能复用 provider 上报的真实用量,条件是最新一次成功调用的规范请求信封(provider / model / tools / 前缀 / 调用配置)与当前测量的信封逐字节一致,且其总量不低于那次调用的完整启发式锚点;之后的成功调用会替换更早的锚点。否则就退回到"对整个信封 + 表面"做完整启发式估算。表面变化(含压缩后的收缩替换)都相对锚点带符号累加,负增量也正确。
会话投影(session projections):给 UI 看的三个量
当组合层提供 ctx.sessionProjections 时,计量器通过一个可选子 fiber 注册三个单元:
| 投影 | 内容 |
|---|---|
tokenUsage | 完整持久化日志的 uncachedInputTokens / outputTokens / cacheReadTokens / cacheWriteTokens(输入/缓存读/缓存写/输出四桶不相交,reasoning 不再二次计入) |
contextPressure | 可选 pressureTokens(provider 上报的最新提示词大小)、可选 projectedTokens、可选 contextWindow(最新 request/context 的路由容量) |
contextBreakdown | 启发式的 systemTokens / toolsTokens / messageTokens——上下文的构成,而非 provider 计费的大小 |
关键在 projectedTokens:下一次请求的提示词要花多少 = provider 采到的样本 + 自采样以来表面增减的启发式重计价(夹在 0)。只有增量被估算,所以它既锚定 provider,又在内容落地(或压缩阴影化)的瞬间反应。这正是它存在的理由:压缩摘要走 ctx.llm.stream() 直接调用、自己不上报任何 usage,所以 pressureTokens 在压缩后、直到下一整轮完成前仍报告压缩前的提示词——occupancy 显示读的是 projectedTokens。
近似是刻意的。occupancy 字段是独立的 last-wins 记录,不是对某次请求的原子观察;切换模型会让新容量先配对上一条路由的旧样本。occupancy 百分比是给人看的参考值,不是计费记录、也不是门禁输入——harness 里没有任何地方拿它做决策,压缩读的是
measure()。CJK 文本和 JSON schema 在"4 字符/ token"下会严重低估,所以contextBreakdown三者不会加出projectedTokens。
七、上下文压缩(compact):不截断,折叠
长会话不用"砍掉开头",而是折叠:
折叠 = 把一段历史"阴影化"(shadowed)
→ 用摘要/报告替换模型可见面
→ 完整日志仍留在持久化层(事件溯源:日志是唯一真源)
→ 模型看到的是 surfaceOp: replace 之后的面
机制要点:
CompactionEngine三个入口:compactIfNeeded/compactNow/compactRegion- 事件序列:
compaction/start→compaction/summary→compaction/end;带compaction/end-seed孤儿锁与恢复(崩溃时不会留下"压缩到一半") - 表面约束:
surfaceOp(append/replace)只在user/messageassistant/messagetool/result上合法;compact 事件本身是 log-only,不入表面 - 压缩压力挂在
agent/pre-step,规范的溢出修复挂在agent/request-error(见 事件系统 与 Agent 主循环)
结果裁剪(compaction-tool-result-pruner):先减料,再摘要
@deepseek-ai/dsh-compaction-tool-result-pruner 提供 ctx.toolResultPruner,是 compaction-basic 的可选配套(默认挂载于 base bundle;web 模式 bundle 显式禁用)。它不是压缩后端、也不是面向模型的工具,而是一次model-free、可重放的裁剪:把超预算的 tool/result 表面节点改写成"有界头 + 固定省略标记 + 有界尾",完整原始事件仍留在 append-only 会话日志里。
- 触发时机:compaction-basic 在压力或规范溢出达标之后、选范围之前读它(
ctx.get('toolResultPrune'));低于压力的 step 检查从不裁剪。 - 改写方式:每处超预算结果被替换成一个新 append 的
tool/result,带{ surfaceOp: { op:'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs:[originalSeq] };替换只改content,保留turn/step/callId/错误字段/meta。 - 裁剪后 remeasure:compaction-basic 再经
ctx.tokenMeter重测,压力降到安全线就跳过摘要;否则对裁剪后的表面做摘要。
| 配置 | 默认 | 含义 |
|---|---|---|
thresholdChars | 8192 | 文本合计超过该 Unicode 码点数才裁剪 |
headChars | 4096 | 保留的头部码点数 |
tailChars | 1024 | 保留的尾部码点数 |
measureContent(blocks)数text块的 Unicode 码点;pruneContent(blocks)返回有界替换,或内容已在阈值内时返回null。非文本块保持原相对位置;切片不会劈开 UTF-16 代理对(但可能劈开多码点的 grapheme 簇)。- 每次输出严格等于"head + 标记 + tail",且严格小于触发输入,所以第二遍不会再次产出替换。
第三方
tool-rewind的 SHRINK "摘要必须比被折叠区域短" 属仓库外插件,其语义无法从当前源码验证:当生态参考即可,别当成 DSH 内置。
八、三视角:模型 / token / KV cache
| 一次组装 | 模型看到 | token 效应 | KV cache 效应 |
|---|---|---|---|
| 系统提示词 | identity + persona + 各插件段(严格插值后) | identity 固定费 token;persona/段文本每请求重付 | identity/persona/段/order 逐字节不变才前缀复用 |
| 工具 schema | 可见工具(经 toolOrder/restriction) | schema 每 step 重付 | schema 变化自首个改动 token 起失效 |
| 会话历史 | surface 消息(不含原始 chunk/边界) | 随表面消息增长;多工具轮次每 step 重发累积 | 普通增长 append-only;surface 替换/压缩使前缀失效 |
实操含义:想省 token/保缓存,保持系统提示词与工具 schema 稳定;任何"动态拼 persona"都会让前缀缓存从改动处失效,费一次全量重算。
九、源码佐证(事件层)
{ "type": "user/message", "seq": 9, "data": {...} }
{ "type": "request/header", "seq": 11, "data": { "header": { "config": {...}, "system": "{{system}}", "tools": "{{tools}}" } } }
{ "type": "request/context","seq": 12, "data": { "provider": "deepseek-official", "model": "deepseek-v4-flash", "contextWindow": ... } }
十、验证
# 看一个会话的请求头(默认 zstd 压缩、两级目录)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "request/header" | head -1
# 看装配后的系统提示词(含 identity + persona + 各段)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -o '"system":.*' | head -1
# 看变量渲染是否生效(严格插值,畸形会 fail)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -i "model\|cwd" | head
下一步
- Agent 主循环:上下文怎么被循环消费(装配是它的一部分)
- 工具执行:工具 schema 背后的执行流水线
- Agent 预设与 Persona:用
complete:true的 persona 完全掌控某 agent 的提示词 - 写一个服务:用
variable()给 agent 注动态事实