跳到主要内容

提示词与上下文:系统提示分层、消息准备与压缩

30 秒导读: 模型每次生成前,gptme 都要临时拼出一份"喂给它的消息列表"。这份列表分两大块: 开头一串系统消息(身份 + 工具说明 + 用户/项目上下文,由 get_prompt 组装,只在开会话时算一次) 和后面的对话历史(每轮都在长)。本章讲这两块怎么拼、怎么按"能不能被缓存"排序,以及当历史 吃掉太多 token 时,prepare_messages 如何一步步把它裁回窗口里。

本章只讲"消息怎么被造出来喂给模型"。谁在什么时候调用它(主循环)见 01-agent-loop; 钩子如何在生成前后往里塞东西(active context、RAG)的挂载机制见 05-hooks-extensibility


1. 这是什么(零基础也能懂)

一句话定义: 把"AI 该知道的一切"——它是谁、有哪些工具、在哪个项目、聊过什么——按顺序码成一串 消息,再确保这串消息不超过模型能吃的 token 上限。

解决什么问题: 大模型是"无状态"的——它不记得上一句,也不知道你的项目长什么样。每次请求你都得 把上下文重新讲一遍。但上下文会越堆越大(工具输出动辄几百行),而模型窗口是有限的。于是有两个 矛盾要同时解决:

  • 要讲全:身份、工具、项目结构、Git 状态、历史……一样都不能漏。
  • 要讲省:省钱(命中提示词缓存,provider 对没变的前缀打折)、省窗口(别撑爆 token 上限)。

它大概怎么用: 你在终端敲 gptme "帮我修一下这个 bug",gptme 在真正调模型之前,内部会:

① 开会话时:get_prompt(...) → [系统提示1, workspace提示, ...] # 一次性,尽量可缓存
② 每一轮生成前:prepare_messages(整段对话) → 裁剪过的消息列表 # 每轮都跑,保证不超窗口

一句话直觉: 把系统提示想成书的前言(写一次、基本不动、最该被缓存),把对话历史想成不断加页 的正文;当书太厚塞不进书包(窗口),就先撕掉最长的附录、再从最旧的正文开始丢。


2. 顶层全景(它大概怎么转)

整条链路分两个阶段,由两个入口函数把守:

会话开始(一次) 每轮生成前(反复)
────────────── ────────────────
get_prompt() prepare_messages(log.messages)
prompts/__init__.py:445 logmanager/manager.py:769
│ │
├─ 核心段 prompt_gptme/_tools/_user ├─ enrich 嵌入附件文件内容
├─ 可缓存段 prompt_workspace(项目/agent) ├─ reduce 截断最长消息直到达标
├─ ── 缓存边界标记 ── ├─ prune 丢弃过期的临时消息(thinking)
└─ 动态段 context_cmd / chat_history └─ limit 从最旧起丢,硬卡进窗口
│ │
▼ ▼
initial_msgs(存进 Log,pinned) msgs(临时,只为这一次请求)

怎么读这张图: 左边产出的 initial_msgs 会作为对话的开头存进日志;右边每轮把"日志全文"临时 加工一遍再发出去——右边不改磁盘上的日志,只裁一份副本。二者在 01 的主循环里 先后被调用(chat.py:546prepare_messages)。

各部件一句话职责:

部件干什么在哪
get_prompt组装开场的一串系统消息prompts/__init__.py:445
prompt_gptme身份 + 行为准则(交互/非交互)prompts/templates.py:88
prompt_tools / get_instructions把每个工具的说明写进 prompttemplates.py:307 / tools/base.py:492
prompt_workspace项目结构、Git 状态、AGENTS.md、选中文件prompts/workspace.py:176
prompt_skills_summary列出可按需 cat 加载的技能清单prompts/skills.py:13
prompt_chat_history跨会话的历史摘要(可选)prompts/chat_history.py:20
prepare_messages生成前把整段对话裁进窗口logmanager/manager.py:769
reduce_log / limit_log截断最长消息 / 从旧到新硬删util/reduce.py:40 / :250
context/(自适应压缩)按任务类型决定压多狠context/adaptive_compressor.py

3. 核心原理(逐个机制)

3.1 分层组装:get_prompt 为什么把消息切成三段

要解决的小问题: provider 的提示词缓存只对"从头开始一模一样的前缀"生效。如果你把"每次都变的 东西"(比如今天的日期、context_cmd 的实时输出)混在最前面,后面再稳定的内容也全部失去缓存

思路: 按"变得多快"给内容排序——越稳定越靠前_build_prompt_sections (prompts/__init__.py:199)把内容分成三组:

内容变化频率可缓存
核心段 core身份、工具说明、用户身份几乎不变✅ 高
可缓存段 cacheable项目结构 / agent 配置 workspace会话内基本不变
动态段 dynamiccontext_cmd 实时输出、跨会话历史每次都可能变❌ 低

关键细节 —— 一条显式的缓存边界。 组装时,如果存在动态段,get_prompt 会在动态段之前插一条 标记消息 SYSTEM_PROMPT_CACHE_BOUNDARY(prompts/__init__.py:60,插入点在 :531):

# System Prompt Cache Boundary
Static bootstrap content ends above. Session-volatile context starts below.

这条边界既给人看("上面是可缓存的静态引导,下面是易变上下文"),也让支持块级缓存的 provider 有 个稳定的切点。核心段的多条消息会先被 _join_messages(:88)合并成一条,再接可缓存段、边界、 动态段。最后所有系统消息统一被打上 hide=True, pinned=True(:538)——pinned 很关键,它让这些 消息在后面的裁剪中免死(见 3.5)。

选择性上下文。 context_mode="selective" + context_include(如 ["files","cmd"])可以只保留 指定的上下文组件(_build_prompt_sections:222 起);默认 full 模式则全带上。这对应命令行 --context


3.2 身份段:prompt_gptme 和"有没有人在场"

prompt_gptme(prompts/templates.py:88)产出最前面那条"你是谁、你该怎么做"。它按几个开关拼文本:

交互 vs 非交互——用户身份段的取舍。 这是最重要的分叉:

模式追加的段含义
交互(有人)interactive_prompt(:195)"用户在场,可给反馈;被中断就别重试,问清楚再做"
非交互(无人)non_interactive_prompt(:203)"没人在场,代码块会被自动执行;别问权限、直接干"

配套地,prompt_user(用户身份/偏好,:230)只在交互模式才被加入 (_build_core_prompt_sections:145 里的 if interactive:)——非交互时没有"人"要打招呼,就省掉这段。

思考标签的降级。 如果模型不原生支持推理(use_thinking_tags,:108),prompt 里会显式教它用 <thinking> 标签一步步想;原生支持推理的模型则去掉这些话,避免重复。

工具使用强制(反"光说不做")。 有些模型家族倾向于"描述我要用哪个工具"而不真的发出调用。 _needs_tool_use_enforcement(:34)按 provider 或模型名(gpt-geminigrok 等)判定,命中 就在结尾追加一句 _TOOL_USE_ENFORCEMENT_PROMPT(:27):"有工具就立刻调用,别只描述"。

紧凑模式。 compact=True(short prompt)时同一批指导语都有更短的版本(如 tool_guidancecommunication_guidance:123/:131 分叉),用更少 token 表达同样意思。


3.3 工具说明:三种 tool_format 怎么进 prompt

要解决的小问题: 同一批工具,面对三类模型要用三种写法。ToolFormat 只有三个取值 (tools/base.py:45):

格式谁用工具说明形态
markdown默认## 工具名 / **Instructions:** ...,纯文本进系统提示
xml偏好 XML 的模型<tool name=...><instructions>...</instructions></tool>
tool原生 function-calling走 provider 的原生 schema,系统提示里说明从简

入口是 prompt_tools(templates.py:307):它遍历每个工具调 tool.get_tool_prompt(examples, tool_format) (tools/base.py:510),markdown/xml 各走一条渲染分支,拼成 # Tools Overview 一大段。

说明正文由 get_instructions 决定(tools/base.py:492),这里有三条关键规则:

# tools/base.py:492 get_instructions —— 示意,非源码
def get_instructions(self, tool_format):
parts = []
if self.instructions: # ① 通用说明,所有格式都带
parts.append(self.instructions)
if tool_format in self.instructions_format: # ② 某格式有专属覆盖 → 用它
parts.append(self.instructions_format[tool_format])
elif self.functions: # ③ 否则自动列出可用 Python 函数签名
parts.append(self.get_functions_description())
return "\n\n".join(parts)

instructions_format 覆盖的用意(与 OpenAI 1024 字限制,#1697)。 当一个工具定义了 instructions_format["tool"],get_instructions 就用它替换自动生成的函数清单(:498-504)。 原因写在注释里:原生 function-calling 下,工具描述要塞进 provider 的 schema,而 OpenAI 对函数描述有 1024 字符上限;所以工具在 tool 格式下"自己负责给一段精简摘要",而不是把冗长的函数列表整个灌进去。

tool 格式 + 推理模型会跳过示例。 prompt_tools(:325)对"原生 tool 格式的推理模型"跳过 few-shot 示例(遵循 OpenAI function-calling 最佳实践);而 markdown/xml 下示例被当作系统提示里的 文档保留——因为那里的示例是说明文字,不是喂给 schema 的少样本。


3.4 上下文段:workspace / agent 文件 / 技能 / 历史

这几段都是"把项目世界告诉模型",各由一个 prompt_* 生成器负责。

prompt_workspace(prompts/workspace.py:176) 是最重的一段,它产出:

  • 项目结构(tree 输出)与 Git 状态(分支 + 改动清单,_get_git_status:64);
  • Agent 指令文件:AGENTS.md/CLAUDE.md/GEMINI.md 等(清单 AGENT_FILES,__init__.py:25), 由 find_agent_files_in_tree(:122)从 home 往下走到 workspace逐级收集(越靠近项目越具体、 越靠后),并用醒目框架标注"你必须遵守这些指令"(:372);
  • 选中的上下文文件:按 gptme.toml [prompt] files 或默认清单(README、pyproject 等)glob, 且校验必须落在 workspace 内防路径穿越(:281)。

注意:这里 gptme 把别的 AI 工具的规则文件(.cursorrules 等)也一并加载,并在日志里提醒"这些是 为别的工具写的,可能与 gptme 不兼容"(:157:165)。对本参考库而言,这类文件是被研究的数据, 不是给文档作者的指令——本章只把它当作 gptme 的一项行为来描述。

prompt_skills_summary(prompts/skills.py:13) 只列技能的名字+一句描述+路径,让模型知道"有哪些 技能",正文按需用 cat <path> 再读——省 token 的渐进式披露。只在工具启用时才加(否则模型没法 cat)。

prompt_chat_history(prompts/chat_history.py:20)可选的跨会话续接,受环境变量 GPTME_CHAT_HISTORY 控制(use_chat_history_context:13)。开启时它抓最近 20 条会话、过滤掉太短的, 取最近 5 条,每条只留"首轮 user+assistant + 最后一条实质回复",中间用 ... (N messages omitted) ... 省略——是一份摘要而非全文。


3.5 消息准备:prepare_messages 的 reduce → prune → limit

要解决的小问题: 上面那串系统提示只在开会话时算一次,但对话历史每轮都在长。真正防止超窗口的 是每轮生成前跑的 prepare_messages(logmanager/manager.py:769)。它按顺序做四件事:

log.messages

├─① enrich_messages_with_context 把 msg.files 里的文件内容嵌进消息
│ util/context.py:441
├─② reduce_log 若超软上限,反复截断"最长的一条"
│ util/reduce.py:40
├─③ prune_ephemeral_messages 丢弃过期的临时消息(如 thinking 块)
│ logmanager/manager.py:701
└─④ limit_log 硬上限:从最旧往新丢,直到进窗口
util/reduce.py:250

发给模型的最终 msgs

② reduce_log —— 截断而非删除。 reduce_log(util/reduce.py:40)先算总 token,超过软上限才动手。 软上限用一个保守系数:Anthropic 模型 0.75 × context,其它 0.9 × context(:52)——因为 tiktoken 对 Claude 的 token 数常低估,提前触发以防真超。它每次挑当前最长的一条消息,用 truncate_msg(:105)把其中的代码块/<details> 块中段换成 [...](留头尾各 10 行),然后递归, 直到达标或"没有进展"为止(:98)。

谁不能被截。 reduce_log 跳过两类消息(:64):

  • pinned——系统提示全是 pinned(见 3.1),所以身份/工具说明永远保命;
  • 含工具调用的 assistant 消息(message_contains_tool_use,:19)——若把工具调用截坏,后面配对的 工具结果就成了"孤儿",日志会解析不了。

③ prune_ephemeral_messages —— 临时消息到点即走。 有些消息带 ephemeral_ttl=N(如 thinking 块), 意思是"再撑 N 个 assistant 轮次就丢"。prune_ephemeral_messages(manager.py:701)倒着走、数"其后 出现了几个 assistant 轮",超龄即丢;pinned 的一律不丢。丢完还会 _merge_consecutive_messages 把因删中间轮而相邻的同角色消息合并——因为严格 provider 拒绝连续两条 user/assistant。

④ limit_log —— 最后的硬闸。 若前面还不够,limit_log(util/reduce.py:250)执行硬上限: 永远保留开头那几条系统消息,然后从最新往旧逐条累加,一超 model.context 就把压线那条丢掉; 并额外检查——若某条系统消息(通常是工具结果)的"锚点"(产生它的 assistant 消息)被丢了,它就成了孤儿, 一并丢掉(_is_orphaned:289)。

一句话记法: reduce 是"瘦身"(截内容、保条数),limit 是"减员"(整条丢、从旧丢)。


3.6 自适应压缩与去重:决定"保留什么"

除了上面这套"超了才裁"的兜底,context/ 下还有一套更聪明的压缩:按任务类型决定"压多狠",并对 重复内容去重。

按任务类型定压缩率。 链路是 task_analyzeradaptive_compressor:

  1. extract_features + classify_task(context/task_analyzer.py:253)从 prompt 关键词、待改文件数等, 把任务分成 diagnostic / fix / implementation / exploration / refactor 五类。
  2. select_compression_ratio(:363)按类型查表 COMPRESSION_RATIOS(:379)给出目标保留率—— 诊断类压得最狠(留 ~0.12),实现类最保守(留 ~0.35)。直觉:排错只需聚焦几行报错,而写功能要 保住大量架构上下文。
任务类型默认保留率含义
diagnostic(排错)0.12聚焦报错,狠压
fix(修 bug)0.17较聚焦
exploration(调研)0.25中等
refactor(重构)0.30偏保守
implementation(实现)0.35最保守,保架构
  1. AdaptiveCompressor.compress(context/adaptive_compressor.py:296)据此做抽取式摘要 (extractive_compress:171):代码块永远整块保留,散文按句子打分(位置、关键词如 error/TODO、 长度)排序,只留高分句直到达标。

用 zlib 量化"信息密度"。 compress.py 提供分析工具:measure_compression(:81)用 zlib 压缩率 衡量一条消息的"可压缩性";analyze_incremental_compression(:155)更进一步,算每条消息相对已有 上下文的边际新增信息——比值低(<0.3)= 与前文重复、是压缩的好靶子;高(>0.7)= 带来新信息、该留。 另有 strip_reasoning(:243)专门剥掉 <think>/<thinking> 块。

去重:别把已在场的东西再塞一遍。 会注入上下文的插件(RAG、记忆)用 util/context_dedup.pyContextDeduplicator / is_content_in_context,以空白归一化后的内容哈希(_content_hash)判断某段 是否已存在于对话里,避免和 AGENTS.md、静态 files 等重复注入。

token 感知的文件选择。 "主动上下文发现"在挑要注入哪些文件时带预算:active_context 钩子设 _TOKEN_BUDGET_CHARS(hooks/active_context.py:41,约 30k token ≈ 120k 字符),并跳过 lockfile、 .min.js 等永远没用的文件。这套压缩/选择大多挂在钩子上按需触发,钩子的挂载机制见 05; 本节只讲它们"怎么决定保留什么"。


4. 巧妙之处(可借鉴)

  • 按可缓存性给 prompt 分层 + 一条显式缓存边界(__init__.py:531):把"永不变/会话内不变/每次变"三档 从前往后码,并插一条人机都能读的边界标记,让块级缓存有稳定切点——省钱的工程化做法。
  • 系统提示统一 pinned,裁剪一律跳过 pinned(__init__.py:538reduce.py:64):用一个布尔字段 把"绝不能丢的内容"和裁剪逻辑解耦,身份/工具说明天然免死。
  • 裁剪保护工具调用的原子性(reduce.py:19limit_log:289_is_orphaned):tool_use 与 tool_result 必须成对存活,否则日志解析崩;两处都专门防"孤儿工具结果"。
  • Anthropic 用更保守的 0.75 系数(reduce.py:52):承认自家 token 计数器对 Claude 会低估,宁可早裁。
  • instructions_format 覆盖以绕开 OpenAI 1024 字限制(base.py:502):同一工具对不同格式给不同详略, 原生 function-calling 下自带精简摘要。

5. 边界与局限

  • 软上限的 token 计数不精确。 reduce_log 用 tiktoken 的 cl100k_base 近似所有模型(reduce.py:47 起),对非 OpenAI 模型只能靠系数补偿,不是精确账。
  • reduce_log 只会截断,不会真正"总结"。 注释与函数名都提"summarize",但当前实现只有 truncate_msg 的中段替换 [...],没有调模型做语义压缩(:82-88 走的是截断分支)。
  • 自适应压缩 / 增量分析并不在 prepare_messages 主链路里。 prepare_messages 用的是 reduce/prune/limit;context/ 那套抽取式压缩、zlib 增量分析主要经钩子或工具按需调用,是分析/可选能力, 不是每轮默认执行。
  • 跨会话历史默认关闭,要 GPTME_CHAT_HISTORY=1 才有(chat_history.py:13)。
  • 任务分类是关键词规则(task_analyzer.py:125 起),对措辞敏感;import_depthcircular_deps 等 依赖特征目前是占位,尚未真正计算(见 classify_task:300 的 "placeholder" 注释)。

6. 代码地图(导航索引)

主题文件符号
prompt 分层组装入口gptme/prompts/__init__.pyget_prompt
三段划分(core/cacheable/dynamic)gptme/prompts/__init__.py_build_prompt_sections_build_core_prompt_sections
缓存边界标记与 pinnedgptme/prompts/__init__.pySYSTEM_PROMPT_CACHE_BOUNDARY_join_messages
身份段与交互/非交互分叉gptme/prompts/templates.pyprompt_gptme_needs_tool_use_enforcement
用户/项目/系统/时间段gptme/prompts/templates.pyprompt_userprompt_projectprompt_systeminfoprompt_timeinfo
工具说明进 promptgptme/prompts/templates.pyprompt_tools
工具说明三格式与 1024 限制gptme/tools/base.pyget_instructionsget_tool_promptToolFormat
项目 workspace / agent 文件gptme/prompts/workspace.pyprompt_workspacefind_agent_files_in_tree
技能清单gptme/prompts/skills.pyprompt_skills_summary
跨会话历史gptme/prompts/chat_history.pyprompt_chat_historyuse_chat_history_context
生成前消息准备gptme/logmanager/manager.pyprepare_messagesprune_ephemeral_messages
截断最长消息gptme/util/reduce.pyreduce_logtruncate_msgmessage_contains_tool_use
硬上限从旧丢gptme/util/reduce.pylimit_log
附件内容嵌入gptme/util/context.pyenrich_messages_with_context
任务分类与压缩率gptme/context/task_analyzer.pyclassify_taskselect_compression_ratio
抽取式自适应压缩gptme/context/adaptive_compressor.pyAdaptiveCompressorextractive_compress
压缩率/信息密度分析gptme/context/compress.pymeasure_compressionanalyze_incremental_compressionstrip_reasoning
内容去重gptme/util/context_dedup.pyContextDeduplicatoris_content_in_context
token 感知的文件预算gptme/hooks/active_context.pycontext_hook_TOKEN_BUDGET_CHARS