数据截至 (上游 commit d87b272aec54)
上下文工程:系统提示、QWEN.md、自动记忆、技能与压缩
30 秒导读: 模型每轮只能看见一个固定大小的窗口。这一章讲 Qwen Code 怎么决定「这一轮往窗口里塞什么」(系统提示 + 项目规约 + 召回的记忆 + 技能清单),以及「窗口快满了扔掉什么」(微压缩、全量压缩、会话落盘)。
1. 这是什么(零基础也能懂)
一句话定义: 上下文工程 = 在有限的上下文窗口里,决定「装什么、装多少、什么时候倒掉」的那一整套工程。
为什么它是个工程问题。 假设你在一个几十万行的仓库里让 AI 改代码。它需要知道的东西远超窗口容量:
| 它需要知道的 | 从哪来 | 如果全塞会怎样 |
|---|---|---|
| 我是谁、该怎么干活 | 系统提示(内置模板) | 固定成本,必须装 |
| 这个项目的规约 | QWEN.md / AGENTS.md | 大项目层层叠加,能吃掉几千 token |
| 用户以前说过的偏好 | 自动记忆目录 | 全塞进去 = 一年后开销爆炸 |
| 某类任务的专门流程 | 技能(SKILL.md) | 几十个技能全展开必然溢出 |
| 之前几十轮聊了什么 | 会话历史 | 长会话必然撞窗口上限 |
Qwen Code 的三招。 这一章就是围绕这三招展开的:
- 分层装配 —— 系统提示不是一坨字符串,而是「基座 + 若干可插拔段」按固定顺序拼起来的。
- 按需召回 —— 记忆和技能只留一份「索引/清单」常驻,正文等到相关时才拉进来。
- 到点压缩 —— 快撞窗口时先做便宜的规则式清理(微压缩),不够再做贵的模型式摘要(压缩)。
一句话直觉: 把上下文窗口当内存,把 QWEN.md、记忆目录、会话 JSONL 当磁盘;系统提示是开机就常驻的内核,记忆召回是按需换页,压缩是内存不够时的 swap。
用起来什么样。 用户几乎察觉不到这一层,只在三处冒头:
$ qwen
> 帮我把 auth 中间件换掉
(后台发生的事,用户看不到:)
· 读到 ~/.qwen/QWEN.md + <repo>/QWEN.md + <repo>/sub/QWEN.md,拼成 userMemory
· 侧查询挑出 2 条相关记忆,包成 <system-reminder> 塞在用户话前面
· 技能清单以 <system-reminder> 增量注入,不动 tools 块
(几十轮之后,用户看得到的:)
· Context left: 22% ← warn 档
· Compressing conversation history… ← auto 档
2. 顶层全景(一次请求里,上下文长什么样)
怎么读这张图: 从上到下就是一次 API 请求的三大块。左列是块名,右框里是这一块由哪些片段拼成、按什么顺序。
┌── system ──────────────────────────────────────────────────────────┐
│ ① 基座提示(Core Mandates / Task Management / Workflows / Tone…) │
│ ② 沙箱段(Seatbelt / 容器 / 无沙箱,三选一) │
│ ③ Actions 段(高风险动作要先问) │
│ ④ Git 段(仅当 cwd 是 git 仓库) │
│ ⑤ 工具调用示例(按模型名选 general / qwen-coder / qwen-vl) │
│ ─────────── 分隔符 "\n\n---\n\n" ─────────── │
│ ⑥ userMemory = QWEN.md 层叠 + .qwen/rules + 「auto memory」提示段 │
│ ─────────── 分隔符 ─────────── │
│ ⑦ appendSystemPrompt(CLI 传入的追加指令) │
│ ⑧ git status(分支 + 近期提交) │
└────────────────────────────────────────────── ──────────────────────┘
┌── tools ───────────────────────────────────────────────────────────┐
│ 工具声明。Skill 工具的 description 是「静态常量」——刻意不放技能清单 │
└────────────────────────────────────────────────────────────────────┘
┌── messages ────────────────────────────────────────────────────────┐
│ 启动 prelude(环境快照 + 可用技能快照) │
│ …历史轮次…(撞阈值后会被整体换成一个 <state_snapshot> 摘要) │
│ <system-reminder>:本轮召回的记忆 / 技能增量 / plan 模式提醒 │
│ 本轮用户输入 │
└───────────────────────────── ───────────────────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 系统提示装配 | 拼出上图 ①–⑧ | packages/core/src/core/prompts.ts |
| 主会话入口 | 决定用内置还是自定义系统提示,末尾贴 git status | packages/core/src/core/client.ts:803(getMainSessionSystemInstruction) |
| 项目上下文发现 | 逐层向上找 QWEN.md/AGENTS.md,处理 @import | packages/core/src/utils/memoryDiscovery.ts |
| 自动记忆 | 抽取 / 整理 / 召回 / 遗忘,统一入口 | packages/core/src/memory/manager.ts |
| 技能 | 发现、解析、监听、按路径条件激活 | packages/core/src/skills/skill-manager.ts |
| 压缩 | 三档阈值 + 模型摘要 + 附件恢复 | packages/core/src/services/chatCompressionService.ts |
| 微压缩 | 规则式清空老工具结果与图片 | packages/core/src/services/microcompaction/microcompact.ts |
| 会话落盘 | JSONL 记录与恢复 | packages/core/src/services/chatRecordingService.ts |
主线走一遍(不进代码): 会话启动时装配 ①–⑧ → 每轮用户输入到来时并行发起「记忆召回」预取 → 组装 <system-reminder> 与用户输入一起发出 → 模型跑完这一轮 → 后台异步跑「记忆抽取」和可能的「做梦」 → 下一轮发送前检查 token 数,够高就先压缩。
主循环本身怎么转见 01 主循环;工具怎么声明与披露见 03 工具层。
3. 系统提示装配:一个函数拼出全部固定成本
3.1 它要解决的小问题
系统提示既要覆盖「怎么干活」的全部纪律,又要随环境变化(有没有沙箱、是不是 git 仓库、用的哪个模型),还要允许用户整段替换。写成一个模板字符串会失控,写成一堆 if 拼接会读不懂。
3.2 思路:一个基座 + 若干函数化的段
getCoreSystemPrompt(packages/core/src/core/prompts.ts:110)返回一个模板字符串,里面内联调用了几个返回段落的函数——沙箱段是一个立即执行函数、Git 段是另一个、动作段是 getActionsSection()、示例段是 getToolCallExamples(model)。段落之间没有条件分支缠绕,各自负责判断自己该不该出现。
整段替换的逃生舱。 两个环境变量把这块变成可外部接管的:
| 环境变量 | 作用 | 代码位置 |
|---|---|---|
QWEN_SYSTEM_MD | 设成路径就从该文件读系统提示;设成 1/true 用默认 .qwen/system.md;设成 0/false 关闭。文件不存在直接抛错 | prompts.ts:120-135 |
QWEN_WRITE_SYSTEM_MD | 把当前基座提示写出到文件,方便 diff 和二次编辑 | prompts.ts:326-339 |
QWEN_CODE_TOOL_CALL_STYLE | 强制选用哪套工具调用示例,绕过按模型名的正则判断 | prompts.ts:804-819 |
解析这两个变量的是同一个 resolvePathFromEnv(prompts.ts:19):它先判断值是不是布尔开关,不是就当路径处理并展开 ~。
3.3 拼接规则:一个 --- 分隔符管所有追加段
所有「追加到基座后面」的东西走同一个 buildSystemPromptSuffix(prompts.ts:350):
// 示意,非源码
function buildSystemPromptSuffix(text) {
const trimmed = text?.trim();
// 空串/空白 → 返回空,不留下孤零零的分隔符
return trimmed ? `\n\n---\n\n${trimmed}` : '';
}
重点看空值处理:没有内容时返回空串而不是空的分隔块。getCoreSystemPrompt 末尾就是 basePrompt + memorySuffix + appendSuffix(prompts.ts:341-347),getCustomSystemPrompt(prompts.ts:78)走同一个后缀函数——所以「用户自定义系统提示」和「内置系统提示」在记忆追加这件事上行为完全一致。
getCustomSystemPrompt 还多做一件事:把 @google/genai 的 systemInstruction 联合类型(字符串 / PartUnion[] / Content / 单个 Part)统一压平成文本(prompts.ts:83-102),这样上游不管以哪种形态传自定义提示,下游都只看到字符串。
3.4 三套工具调用示例,按模型名选
getToolCallExamples(model)(prompts.ts:802)在三份常量里选一份:
| 常量 | 触发条件(正则) | 定义处 |
|---|---|---|
qwenCoderToolCallExamples | /qwen[^-]*-coder/i 或 /coder-model/i | prompts.ts:546 |
qwenVlToolCallExamples | /qwen[^-]*-vl/i | prompts.ts:703 |
generalToolCallExamples | 兜底 | prompts.ts:470 |
判断前有一行 model.length < 100 的长度护栏,避免超长模型名喂给正则。这是「同一份系统提示按后端模型微调示例风格」的做法;多协议模型层本身见 02 多协议模型层。
3.5 两个不进主提示的专用提示
它们不属于主循环的系统提示,但同属「上下文工程」的产物:
getCompressionPrompt()(prompts.ts:392)—— 压缩用的系统提示。要求摘要模型先在<analysis>里打草稿,再吐一个 9 段的<state_snapshot>XML(primary_request_and_intent/key_technical_concepts/files_and_code_sections/errors_and_fixes/problem_solving/all_user_messages/pending_tasks/current_work/next_step)。注意它不含"别跟用户打招呼"那句收尾指令——那句由postProcessSummary单独贴,理由见 §7.3。getProjectSummaryPrompt()(prompts.ts:445)—— 生成落盘的项目摘要 markdown(Overall Goal / Key Knowledge / Recent Actions / Current Plan),供跨会话参考。
4. 项目上下文文件:QWEN.md 的层叠与 @import
4.1 它要解决的小问题
项目规约需要能分层:全局偏好写在 ~/.qwen/QWEN.md,仓库规约写在仓库根,子目录规约写在子目录,个人私货写在一个 gitignore 掉的文件里——而且要兼容业界的 AGENTS.md。
4.2 四个文件名,各司其职
packages/core/src/memory/const.ts 定义了全部命名:
| 常量 | 值 | 语义 | 行 |
|---|---|---|---|
DEFAULT_CONTEXT_FILENAME | QWEN.md | 主上下文文件 | const.ts:7 |
AGENT_CONTEXT_FILENAME | AGENTS.md | 兼容名,与 QWEN.md 并列参与逐层搜索 | const.ts:8 |
LOCAL_CONTEXT_FILENAME | QWEN.local.md | 个人私有槽位,不参与逐层搜索 | const.ts:28 |
MEMORY_SECTION_HEADER | ## Qwen Added Memories | /memory add 追加内容的锚点 | const.ts:29 |
默认的文件名列表是 [QWEN.md, AGENTS.md],QWEN.md 在前是为了 /init 的向后兼容。setGeminiMdFilename(const.ts:39)允许覆盖;getCurrentGeminiMdFilename(const.ts:49)在数组里挑第一个非空项——注释里记了一段真实事故:daemon 侧的 extractContextFilename 会跳过空项而这里当初不会,于是父进程写 AGENTS.md、子进程读 '',/init 出来的文件成了孤儿。
packages/core/src/tools/memory-config.ts 只是把这些常量再导出一遍(全文 19 行),目的是让只要文件名的调用方不必加载整个 memoryTool 模块——一个很小但很典型的加载成本优化。
4.3 分层发现的顺序
getGeminiMdFilePathsInternalForEachDir(packages/core/src/utils/memoryDiscovery.ts:94)对每个候选目录做这件事:
┌─────────────────────────────┐
对每个文件名 │ QWEN.md,然后 AGENTS.md │
(外层循环) └──────────────┬──────────────┘
▼
① 全局:~/.qwen/<文件名> ← 总是先加
│
┌──────────────────┴──────────────────┐
cwd 是家目录 cwd 是普通目录且 folderTrust
│ │
② 只看 ~/<文件名> ③ 从 cwd 逐层向上找到「项目根的上一层」
用 unshift 保证「越靠外越先」的顺序
│
▼
④ 扩展提供的上下文文件路径(extensionContextFilePaths)
│
▼
⑤ <projectRoot>/.qwen/QWEN.local.md(单一固定槽位,最后加载 → 可覆盖共享规约)
第 ③ 步的向上扫描有两个停止条件(memoryDiscovery.ts:173-196):撞到 ~/.qwen 目录就 break;走到 ultimateStopDir(项目根的父目录,没有项目根则是家目录的父目录)就停。项目根的判定是「最近的含 .git 目录或 .git 文件的祖先」——后者覆盖 worktree 和 submodule。
第 ⑤ 步的守卫值得单看(memoryDiscovery.ts:508):本地槽位要求真实的 foundRoot,不接受「cwd 兜底」。注释写清了两个反例——非 git 工作区里深 cwd 会让「单一槽位」退化成「每个 cwd 一个」;cwd === homedir 会把槽位解析到 ~/.qwen/QWEN.local.md,和全局目录撞车。
拼接时每份文件都被包上路径标记(memoryDiscovery.ts:354 的 concatenateInstructions):
--- Context from: sub/QWEN.md ---
(内容)
--- End of Context from: sub/QWEN.md ---
另外 createMemoryTypeClassifier(memoryDiscovery.ts:407)会给每份文件打上 extension / user / local / project 标签,用于钩子事件通知——钩子体系见 04 安全护栏。
4.4 @import:把上下文文件拆成小块
processImports(packages/core/src/utils/memoryImportProcessor.ts:209)支持 @path/to/file 语法。四个设计点:
| 关切 | 做法 | 位置 |
|---|---|---|
别把代码块里的 @ 当导入 | 先用 marked 词法分析拿到全部 code / codespan 区间,命中就原样保留 | memoryImportProcessor.ts:165(findCodeRegions) |
| 别无限递归 | maxDepth: 5,processedFiles 集合防环,重复文件写成 <!-- File already processed --> | memoryImportProcessor.ts:228、:366 |
| 别被路径穿越 | validateImportPath 拒绝 URL,并要求解析后的路径落在项目根之内 | memoryImportProcessor.ts:424 |
| 两种输出形态 | tree 用 <!-- Imported from: x --> 内联包裹;flat 把所有文件去重后按 --- File: x --- 平铺 | memoryImportProcessor.ts:239、:343 |
一个细节很讲究:找导入用的是手写扫描而非正则(findImports,memoryImportProcessor.ts:89)——要求 @ 前是空白(词边界),@ 后第一个字符是 . / / 或字母。文件不存在时原样保留 @path 文本,因为它多半根本不是导入,只是一句提到 @ 的散文。
4.5 装配进系统提示
Config.refreshHierarchicalMemory(packages/core/src/config/config.ts:2360)调 loadServerHierarchicalMemory 拿到 memoryContent,再把 .qwen/rules/ 的基线规则接在后面,最后(当托管记忆可用时)用 memoryManager.appendToUserMemory(...)(config.ts:2471)把「auto memory」提示段追加上去,setUserMemory 存起来。这个字符串就是 §2 图里的 ⑥。