harmes agent 提示词的构建过程

一、提示词构建

agent 会话的生命周期中,有哪些地方涉及提示词,提示词是如何构建的?

Prompt 全景:涉及 Prompt 的所有场景

复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│                    Prompt 出现的 5 大场景                                      │
│                                                                              │
│  ① 主回合 System Prompt        --- 冻结不变,会话的"宪法"                         │
│  ② 主回合 User Prompt          --- 用户每轮输入 + skill 注入 + 环境注入             │
│  ③ 辅助模型 Prompt (Aux)       --- 压缩、标题生成、记忆、搜索、审查                 │
│  ④ 压缩 Summary Prompt        --- 压缩时对历史对话做结构性摘要                      │
│  ⑤ Skill Prompt (可选)         --- slash 命令注入、skill 执行时的一次性指令          │
└──────────────────────────────────────────────────────────────────────────────┘

核心设计洞察:只有 System Prompt 是"长生命"的,其他 Prompt 都是"短生命"的(一轮用完即焚)。 整个 prompt 系统围绕"主回合 System Prompt 必须稳定以命中 prefix cache"这一铁律设计。


1.1 主回合 System Prompt:冻结的"宪法"

设计思想

System Prompt 在第一次 turn 构建后字节稳定,整个会话生命周期内永不变。这是 prompt caching invariant 的核心------所有主流 LLM(Anthropic Claude、OpenAI GPT、Codex)的 prefix cache 机制都要求前缀字节完全一致才命中。一旦你在中间修改了 system prompt,整个对话的 cache prefix 全部失效,每轮都变成冷启动,token 成本和延迟指数级上升。

构建逻辑(按组装顺序)

System Prompt 是多个"区块"拼接的,每个区块有自己的来源、信任级别和注入时机:

复制代码
System Prompt
│
├─ [1] SOUL.md --- 人格/身份定义
│   来源: ~/.hermes/SOUL.md(用户自己写的)
│   信任级别: 高(用户自己的文件)
│   注入时机: init_agent 时一次性加载,冻结快照
│   安全: 做注入扫描(ignore previous instructions 模式会 WARN 但仍加载,因为是用户自己的身份文件)
│
├─ [2] Platform & Surface Hints --- 我是谁、在哪运行
│   来源: agent 的 platform 参数("cli", "telegram", "desktop"...)
│   内容: "你运行在 Hermes CLI 中"、"当前是 Linux + Python 3.11"、"工作目录是 xxx"
│   设计: 这些是**静态描述**,不是动态命令------告诉模型"它处于什么环境",而不是"它应该做什么"
│
├─ [3] CWD Context Chain --- 项目级开发指南
│   来源: 从 git root 到 cwd,逐层扫描 AGENTS.md / .cursorrules / CLAUDE.md
│   优先级: 越深(越靠近 cwd)的层级优先级越高
│   安全: 做注入扫描(项目 checkout 来的文件是 BLOCKED,不是 WARN)
│   超时: 每个文件有 5s daemon 线程超时,防止 iCloud Drive / NFS 这类网络盘冷读卡死整个启动
│   大小: 有上限,超长会截断(保头保尾,截断中间,日志记录)
│
├─ [4] Skills Index --- 已安装技能目录
│   来源: skills/*.md + optional-skills/*.md 的 frontmatter
│   内容: 每个 skill 的名称、描述、触发条件
│   设计: 不是把每个 skill 的完整内容塞进去,而是塞一个**索引**------模型需要时再主动用 skill_view 工具取完整内容
│   好处: System Prompt 保持精简,同时模型知道有什么可用
│
├─ [5] Runtime Environment --- 运行时事实
│   来源: 进程启动时探测
│   内容: OS 类型、Python 版本、Git 分支/状态、当前 cwd、可用工具数
│   设计: 纯事实陈述,不嵌入意图
│
├─ [6] Memory 冻结快照 --- 用户记忆
│   来源: MEMORY.md + USER.md(磁盘持久化的记忆文件)
│   设计: **冻结快照**------加载后就不再更新。中途 memory provider 写入新记忆,不会刷新到已冻结的 system prompt 里。
│   原因: 保持 prefix cache 命中。如果每轮都刷新 memory 区块,整个前缀就变了。
│   替代方案: 新记忆通过**后续 turn 的 tool result** 注入(cache-safe 的位置,因为 tool result 在消息链的尾部)
│
└─ [7] Ephemeral Add-ons --- 调用方临时注入
    来源: AIAgent 构造参数(ephemeral_system_prompt)
    设计: 留给上层(如 Desktop app、API server)注入一次性补充
    生命周期: 同 system prompt 一起冻结

设计权衡的亮点

为什么 skill 只塞索引不塞全文? 因为 skill 可能有几百个,每个几千字------全塞进去 system prompt 会膨胀到几万 token,每轮都发一遍浪费。塞索引让模型按需取,省 token,同时 system prompt 体积稳定(索引的大小变化远小于全文)。

为什么 memory 要冻结? 因为 memory provider 可能在后台异步写入(向量数据库更新、外部 memory API 拉取)。如果 system prompt 动态刷新,刷新的时机和内容大小都不确定,cache 命中率会剧烈波动。冻结 = system prompt 的体积和内容在会话生命周期内完全可预测。


1.2 主回合 User Prompt:每轮动态注入

设计思想

User Prompt 是短生命的 ------每轮构建、每轮发送、用完即焚。它不影响 system prompt 的 cache 命中,因为它在消息链的尾部。

User Prompt 的内容构成

复制代码
User Prompt
│
├─ 用户原始输入          "帮我写个 React 组件"
│
├─ Skill slash 注入      "/skills 搜索" → 等价于一个 skill 的触发
│   设计: 作为 user message 注入(不改变 system prompt)
│
├─ 环境通知              [SYSTEM NOTICE --- run time budget nearly exhausted]
│   设计: 追加到**最新的 tool result** 里(不是新建一条 user message)
│   原因: 维持 role alternation(assistant → tool → user 的链不能断)
│   cache 安全: tool result 在消息链尾部,改它不影响前面的 cache
│
└─ 迭代预算警告          同上,追加到 tool result

设计权衡

为什么 slash 命令不改变 system prompt? 因为改 system prompt = 打断 cache。Hermes 的做法是把 slash 命令的"效果"做成一条新的 user message 或者 tool result 的增量注入------这两种位置都在消息链尾部,prefix cache 完全不受影响。


1.3 辅助模型 Prompt (Aux):独立路由的"专职"模型

设计思想

主模型只有一个,但 Hermes 可以配置多个辅助模型,各自干不同的专职活:

任务 辅助模型 为什么独立
上下文压缩 auxiliary.compression 压缩可能调用比较频繁,用便宜/快的模型
会话标题生成 auxiliary.title 低质量要求,省 token
记忆提取/更新 auxiliary.memory 需要理解上下文但不需要写代码
会话搜索 auxiliary.search 可能需要 embedding 能力
背景审查 auxiliary.review 长任务,异步跑,别占主模型槽

Prompt 构建原理

辅助模型的 Prompt 和主模型完全独立构建------它有自己的 system prompt、自己的 message 组装逻辑、甚至可以走完全不同的 provider(比如主模型用 Anthropic,压缩模型用 OpenRouter 上的便宜模型)。

关键设计点 :辅助模型的 API 调用会标记为 aux_call,它不会触发主回合的 pre/post_api_request 事件钩子(否则主回合的流会被辅助调用打断),也不共享主模型的 rate limit 池。


1.4 压缩 Summary Prompt:打断 cache 的唯一合法手段

设计思想

当上下文膨胀到 50-85% 时,Hermes 启动会话压缩 。压缩是唯一被允许修改历史消息的机制 ------因为它会把旧消息替换成结构化摘要,整个消息链重写。这意味着 prefix cache 会被打断,但 Hermes 接受这个代价,因为:

一个 80% 膨胀的上下文,即使命中 cache,每轮发送的 token 成本也比"压缩后重建 cache"要高。

压缩 Prompt 的设计

压缩不是一句话"帮我总结上面的对话"------它有结构化的 Prompt 体系:

复制代码
压缩 Prompt
│
├─ System Prompt: 告诉辅助模型它是"会话压缩器",输出格式是什么
│   要求: 必须保留所有用户意图、已完成的工作、待办事项、代码片段、关键决策
│   约束: 不能丢工具调用链,不能改角色,不能简化到模型以后看不懂
│
├─ User Prompt: 把历史对话分批喂进去
│   策略: 先剪枝旧的 tool results(最大 token 节省),再对剩余对话生成摘要
│   分批: 如果整个对话塞不下,分批次压缩再合并
│
└─ 输出约束: 结构化 JSON / Markdown,主 agent 解析后替换原消息链

压缩后的 cache 恢复

压缩完成后,消息链被重写,system prompt 不变但下面的 messages 变了------prefix cache 从压缩点开始重建。Hermes 接受这个代价,因为压缩后上下文会小很多,重建 cache 的成本 < 持续发送膨胀上下文的成本。


1.5 Skill Prompt:两种形态

形态 A:Slash 命令注入

用户敲 /skills install xxx → Hermes 把这条 slash 命令转译成一个 skill 执行指令,作为 user message 注入到当前对话里。

形态 B:Skill 内容的延迟加载

System prompt 里只有 skill 的索引 (名字 + 一句话描述)。当模型决定"我需要用某个 skill"时,它调用 skill_view 工具------tool handler 读取 skill 文件的完整内容,作为 tool result 返回给模型。完整 skill 内容是 tool result,不是 system prompt。

复制代码
为什么不把 skill 内容直接塞 system prompt?
→ skill 可能几百个,每个几千字,system prompt 会膨胀到无法承受
→ 塞索引 = system prompt 体积稳定(索引大小变化远小于全文)
→ 模型按需取 = 只在需要时才花 token 发
→ tool result 位置 = cache safe,不影响 prefix

1.6 Prompt Cache 边界设计

这是整个 prompt 系统最精妙的设计之一。理解它,就理解了为什么有些东西能改、有些绝对不能改:

复制代码
API 请求发送时的 messages 链:

[0] system prompt          ← 冻结!prefix cache 的核心
[1] user message #1        ← 冻结(已发送)
[2] assistant message #1   ← 冻结
[3] tool result #1         ← 冻结(但压缩可能改写)
[4] user message #2        ← 冻结
...
[N] 最新的 tool result     ← 可以追加通知(run budget warning 等)
[N+1] 最新的 user message  ← 本轮新发送的

prefix cache 覆盖 [0..K],K 是两个相邻请求之间不变的最长前缀

可以安全修改的位置:

  • 消息链尾部最新的 tool result(追加通知)
  • 本轮新发送的 user message
  • tool result 里追加 metadata(不改变已有内容,只是 append)

绝对不能修改的位置:

  • System prompt 的任何部分
  • 已发送过的 user / assistant / tool 消息(压缩除外)

Hermes 的所有 prompt 注入都遵守这一规则------slash 命令、budget warning、compression notice、subdirectory hints,全部走"追加到尾部 tool result"或"作为新 user message"的路线。


总结:Prompt 系统的设计原则

原则 含义
System Prompt 冻结 第一次构建后字节稳定,直到会话结束
Skill 用索引不用全文 控制 system prompt 体积,模型按需加载
Memory 用冻结快照 后台更新走 tool result,不刷新 system prompt
只有压缩能改历史 代价是打断 cache,但 token 净收益为正
注入全部走尾部 slash、通知、警告 → 最新 tool result 或新 user message
辅助模型独立路由 压缩、标题、记忆用专职便宜模型,不抢主模型
平台 surface 不进 check_fn surface 信息(我在 Desktop / 我在 CLI)走 system prompt 的 platform hints,不是工具可用性检查

这套设计的底层逻辑是:每轮 API 调用都会发送完整的 messages 链,任何不必要的字节都是持续的成本 。所以 prompt 系统的所有决策,本质上都在做一件事------在不影响模型能力的前提下,最小化每轮发送的 token 量,最大化 prefix cache 命中的稳定性。


二、提示词中的工具

系统提示词里面,怎么没有 mcp、工具等信息,如果 agent 中注册了 mcp 或者工具信息,模型怎么知道?

核心答案:工具/MCP 不走 System Prompt,走 API 专门字段

复制代码
传统做法(已经过时):
  System Prompt: "你可以使用以下工具:\n1. terminal --- 执行终端命令...\n2. web_search --- 搜索网页..."
  messages: [...system_prompt, user_message, ...]
  
现代做法(Hermes 采用):
  System Prompt: "你是一个有工具调用能力的 AI Agent"   ← 只需要告诉模型"有这个能力"
  messages: [...system_prompt, user_message, ...]
  tools: [{name: "terminal", description: "...", parameters: {...}},  ← API 独立字段
         {name: "web_search", ...}, 
         ...所有内置工具 + MCP 工具...]

这是一个架构级的分离 :System Prompt 负责"告诉模型它是谁、知道什么、在什么环境",而 API 的 tools 字段负责"告诉模型它能做什么"。


完整链路:工具/MCP 如何到达模型

复制代码
init_agent() 阶段
│
├─ 内置工具: discover_builtin_tools()  ← AST 扫描 + import 触发 registry.register()
│
├─ MCP 工具: discover_mcp_tools()  ← 连接 config 里配置的所有 MCP server (stdio/SSE/HTTP)
│            对每个 server:
│              ├─ 连接握手 (initialize)
│              ├─ 请求 tools/list → 拿到每个 MCP 工具的 schema
│              └─ 在本地 registry 里 register(schema 来自远端 MCP server)
│
└─ Plugin 工具: plugins/*.py 在 import 时 ctx.register_tool()
   (跟内置工具走同一个 registry)

→ 此时 registry 里已经汇总了: 内置工具 + MCP 工具 + Plugin 工具

turn 开始时
│
├─ model_tools.get_tool_definitions(enabled_toolsets, disabled_toolsets)
│   ├─ 从 TOOLSETS 过滤出当前会话可用的工具(按 toolset membership)
│   ├─ 对每个工具跑 check_fn(检查 API key、服务可达等;TTL cache)
│   ├─ MCP 工具也过同样的 check_fn(server liveness probe)
│   ├─ 过滤掉不可用的、被禁用的、surface 不支持的
│   └─ 输出: [tool_schema_1, tool_schema_2, ...]  ← 这就是发给模型的 tools 列表
│
└─ 组装 API 请求:
     ├─ messages: [system_prompt, user_message, ...]
     ├─ tools: tools_for_api   ← 单独字段,不是 messages 的一部分
     └─ 其他 api_kwargs (model, temperature, max_tokens, stream=true, ...)
     
→ 发送到 LLM API

这个设计比"塞 System Prompt"好在哪里

1. 工具列表本身不影响 prompt cache

System Prompt 冻结不变,但工具列表理论上是会变的(MCP server 断了重连、动态开关 toolset、插件热加载)。如果工具描述塞在 System Prompt 里,工具一变,整个 cache prefix 就断了。

API 独立字段的好处:工具列表在 messages 链之外,它的变化不会破坏 messages 链的 prefix cache。这是两个独立的 cache 维度。

2. LLM API 原生支持、原生解析

现代 LLM(GPT-4、Claude 3.5+、Codex、Gemini)都有原生的 tool use / function calling 协议:

json 复制代码
// 模型返回的 tool call(原生格式,不是模型"瞎编"的)
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_abc123",
      "type": "function",
      "function": {
        "name": "terminal",
        "arguments": "{\"command\": \"ls -la\"}"
      }
    }
  ]
}

这和"模型从 system prompt 里看到一段工具描述,然后自己编一段 JSON 格式的调用"完全不同------原生 tool use 有 schema 约束、有类型校验、有专门的错误码,可靠性高得多。

3. 工具 schema 有结构化格式,不是自然语言

json 复制代码
// API 的 tools 字段 (OpenAI 格式)
{
  "type": "function",
  "function": {
    "name": "terminal",
    "description": "Execute a shell command in the project directory",
    "parameters": {
      "type": "object",
      "properties": {
        "command": {"type": "string", "description": "Shell command to execute"},
        "cwd": {"type": "string", "description": "Working directory"}
      },
      "required": ["command"]
    }
  }
}

而不是在 system prompt 里写一段:

复制代码
你有一个 terminal 工具,可以执行 shell 命令。
用法示例: terminal(command="ls", cwd="/path")
参数 command 是必需的,cwd 是可选的...

结构化 schema 的好处:

  • 模型更不容易 hallucinate 参数名(schema 告诉它"你只能用这几个参数")
  • 参数类型明确(string / number / enum / required)
  • API 层可以做 自动参数校验(模型返回的 arguments JSON 对照 schema 检查,不合格直接报错,不会送到 handler 里炸)
  • MCP server 自带这个 schema → 发现即注册,零适配成本

4. 工具数量可以很多(Hermes 默认 40-60 个,含 MCP 可达 100+)

如果全塞 System Prompt,每轮都是"你可以使用以下 N 个工具:...",工具描述再简洁也得几万 token。而且工具越多,模型找对工具的认知负担越大。

API 独立字段 + 原生 tool use 的设计,把工具发现和工具调用的复杂度交给了 API 层和模型的 tool-calling 能力,System Prompt 只需要一句话:"你是一个有工具调用能力的 AI Agent"。


MCP 工具和内置工具的统一处理

Hermes 把 MCP server 当成一个动态的"工具提供方" ,它返回的每个 MCP tool schema,和内置工具在本地 registry 里是完全等价的:

复制代码
registry 里的每个工具条目,来源可以是:
  ├─ 内置 tools/*.py  → registry.register()
  ├─ Plugin plugins/*.py  → ctx.register_tool()
  └─ MCP server tools/list 返回  → registry.register()

对模型来说,它们是一样的------都是 tools 数组里的一个 entry,都走原生 tool use 协议
对 dispatch 来说,它们也是统一的:
  handle_function_call(name, args)
    → registry.dispatch(name, args)
      → 匹配到 handler
      → 内置工具: 直接调用本地 Python 函数
      → MCP 工具: 通过 MCP 协议转发到远端 server,拿结果回来

这个统一设计的好处是:MCP server 挂了只影响那几个 MCP 工具,不会影响其他工具;同样,工具级别的 check_fn 可以独立检测每个 MCP server 的 liveness,一个 server 断了只 disable 它提供的工具,其他工具继续正常工作。


总结

维度 System Prompt 里的内容 API tools 字段
是什么 一句话:"你有工具调用能力" 完整的结构化 schema 数组
变化性 冻结不变 可动态增删(MCP 断连重连、toolset 切换)
对 cache 的影响 有------改了就断 cache 独立维度------不影响 messages 链的 cache prefix
格式 自然语言 JSON Schema(结构化、可校验)
来源 prompt_builder 组装 registry 统一收集 → get_tool_definitions 过滤
MCP 参与方式 --- MCP server tools/list → 注册到 registry → 和内置工具一视同仁
数量上限 受 prompt token 预算约束 受 provider 限制(通常 128-256 个),Hermes 默认 40-60

这个设计的底层思想是关注点分离 :System Prompt 管"认知边界"(我是谁、我知道什么),API tools 字段管"能力列表"(我能做什么)。两者解耦,各自独立演进,各自不破坏对方的稳定性。


三、服务端对提示词中工具的处理

既然API 传递tools,System Prompt又不知道,模型在推理的时候,只接收Prompt,那模型在执行某个任务的时候,模型怎么知道哪些工具可以使用,我想知道api 请求到了模型这一侧,模型端是怎么处理的?

直接回答:模型确实"知道",但不是因为 System Prompt 写了,而是因为 API 请求到模型侧时,框架会把 tools 序列化成文本(或特殊 token)注入到 Prompt 中,KV cache 的命中情况取决于序列化位置是否变化。


1. 模型端到底怎么处理 tools?

当你调用 API 传递 tools 参数时,模型服务端(如 vLLM、SGLang、TGI 或闭源 API)会执行以下操作:

序列化注入(Text Serialization)

服务端将 tools 列表转换为模型能理解的文本格式,拼接在 System Prompt 之后、User Query 之前:

text 复制代码
<|im_start|>system
You are a helpful assistant.
<|im_end|>
<|im_start|>user
<|im_start|>tools  <!-- 注入位置 -->
[Tool A: {...}, Tool B: {...}]
<|im_end|>
<|im_start|>user
帮我查一下北京天气
<|im_end|>

不同模型的序列化格式不同:

  • Qwen :<|im_start|>user\n<tools>...</tools>\n<|im_end|>
  • Llama 3.1/3.2 :<|start_header_id|>tools<|end_header_id|>...
  • GPT-4 :内部使用 functions. 前缀或 JSON schema 注入

特殊 Token 注入(Native Tool Calling)

新一代模型(Qwen2.5、Llama 3.1、DeepSeek-V3 等)在训练时就见过 <|tool_call|>、<|tool_response|> 等特殊 token。服务端直接用这些 token 包裹 tools 定义,模型能结构性地区分"这是工具定义"和"这是普通对话"。

关键点 :无论哪种方式,tools 最终都会变成模型输入 token 序列的一部分。模型看到的不再是"纯 Prompt",而是包含了工具描述的扩展上下文。


2. tools 变化时,KV cache 会不会失效?

会,且影响显著。 这取决于 tools 在序列中的位置和变化范围:

场景 A:tools 定义变化(增删改工具)

假设原始序列:

text 复制代码
[System] [Tools_v1] [User Query] [Assistant Response]

如果 tools 参数从 v1 变成 v2(比如新增一个工具、修改参数描述):

  • Tools_v1 部分的 KV cache 完全失效:因为 token 序列变了,Attention 计算结果不同
  • User Query 之后的部分也失效:因为 Prompt 前缀变了,后续所有 token 的 KV 都依赖前缀

结论 :tools 变化会导致前缀缓存(Prefix Cache)未命中,需要重新计算整个序列的 KV。

场景 B:tools 不变,仅 User Query 变化

text 复制代码
[System] [Tools_static] [User Query_A] → 缓存命中
[System] [Tools_static] [User Query_B] → 前缀 [System]+[Tools_static] 部分命中,仅 Query 部分重新计算

这是 vLLM PagedAttention 的优势:静态前缀(System + Tools)可以跨请求共享,即使 Query 不同,只要 Tools 不变,这部分 KV cache 可以直接复用。

场景 C:tools 数量对性能的影响

  • 工具越多:注入的 token 越长,Prefill 阶段越慢,KV cache 占用越大
  • 工具越复杂(长 JSON Schema):同样增加 Prefill 负担
  • 频繁变更 tools:导致缓存频繁失效,吞吐量下降

3. 工程优化建议

如果你在设计高并发的 Tool Calling 系统:

稳定 tools 定义

  • 将 tools 定义放在 System Prompt 的固定位置,并保持顺序稳定,最大化前缀缓存命中率
  • 避免在 tools 中插入动态内容(如时间戳、随机 ID),这会导致缓存 100% 未命中

利用 vLLM 的 Automatic Prefix Caching

vLLM 默认开启 APC(Automatic Prefix Caching),会自动缓存 System + Tools 的前缀 KV。只要 tools 不变,后续请求即使 Query 不同,也能复用这部分缓存。

减少 tools 数量

  • 不要一次性传 50 个工具,模型注意力会分散,且 Prefill 成本高
  • 使用 Tool Retrieval:先根据 Query 检索出 Top-3 相关工具,再传给模型

使用 Native Tool Calling 模型

Qwen2.5、Llama 3.1 等模型对 tool 格式做了专项训练,能更准确地理解 tools 边界,减少序列化 token 浪费。


总结

问题 答案
模型怎么知道有 tools? 服务端将 tools 序列化为 token 注入 Prompt,模型通过训练学会识别这些 token
tools 变化影响 KV cache? 是,tools 部分及后续所有 token 的 KV 都需重新计算
如何优化? 保持 tools 静态、利用前缀缓存、减少工具数量、使用 Native Tool Calling 模型

有个矛盾,既要保持tools 固定不变,提高命中缓存,如果 tools 太多,使用 Tool Retrieval,在传递给模型,

工程矛盾:缓存命中率 vs 工具覆盖率 。这不是二选一,而是需要通过分层缓存策略 和检索时机优化来解决。

1. 矛盾的本质拆解

复制代码
高并发场景下的冲突:
├─ 缓存友好:希望 [System + Tools] 是静态前缀,所有请求共享
├─ 检索友好:希望根据 Query 动态选择 Top-K 工具,减少噪音
└─ 结果:动态 Top-K 导致前缀碎片化,缓存命中率暴跌

核心洞察 :Tool Retrieval 本身也有冷热分层 。高频工具(如 get_current_time、web_search)几乎每次都被检索到,而长尾工具(如 delete_user_account)很少触发。

2. 分层缓存架构(Hot/Warm/Cold)

策略:静态热工具 + 动态长尾工具

python 复制代码
# 分层策略示例
HOT_TOOLS = [  # 静态层,永远不变,占 80% 调用量
    "web_search", 
    "calculator", 
    "get_current_time",
    "code_interpreter"
]

WARM_TOOLS = [  # 半静态层,按业务线分组缓存
    "query_order_status",   # 电商组
    "refund_apply",         # 电商组
    "book_flight",          # 出行组
]

COLD_TOOLS = [  # 动态层,实时检索,接受缓存未命中
    "internal_data_query_xxx",
    "legacy_system_yyy"
]

请求组装逻辑:

text 复制代码
[System Prompt] 
+ [Hot Tools]           ← 静态前缀,高缓存命中
+ [Warm Tools Group]    ← 按业务线分组,组内命中
+ [Retrieved Cold Tools] ← 动态部分,允许未命中
+ [User Query]

缓存命中率分析

  • Hot 层:100% 命中(所有请求共享)
  • Warm 层:按业务线隔离,同一业务线内命中(如电商客服会话)
  • Cold 层:未命中,但仅占 10-20% 的 Prefill 开销

3. 检索时机优化(Prefill vs Decode)

更激进的优化:将 Tool Retrieval 从 Prefill 阶段移到 Decode 阶段(多轮工具调用场景)。

传统方式(单轮)

text 复制代码
用户 Query → 检索 Tools → 拼接 Prompt → 生成答案
             ↑ 这里发生,影响 Prefill 缓存

多轮 Agent 方式(延迟检索)

text 复制代码
Round 1: 
  Prompt: [System] + [Hot Tools] + [Query]  ← 无 Cold Tools,缓存命中
  模型输出: "我需要查询内部数据,请提供 tool_x"

Round 2:
  服务端根据模型意图,动态注入 tool_x 的 Schema
  Prompt: [System] + [Hot Tools] + [tool_x] + [Query] + [历史]  ← 仅 tool_x 部分新增
  模型输出: 调用 tool_x 的结果

适用场景 :复杂 Agent 任务,模型需要先"思考"再决定用什么工具。此时工具检索是对话中途发生的,不破坏首轮缓存。

4. 结构化前缀缓存(Prefix Tree)

如果必须做实时 Tool Retrieval,利用 vLLM 的 Automatic Prefix Caching 特性,将工具按树状结构组织:

text 复制代码
缓存节点树:
├─ [System]                    ← 所有请求共享
├─ [System + Hot_Tools]        ← 80% 请求共享
├─ [System + Hot_Tools + Category_A_Tools]  ← 电商类请求共享
├─ [System + Hot_Tools + Category_B_Tools]  ← 出行类请求共享
└─ [System + Hot_Tools + ... + Retrieved_Tool_X]  ← 叶子节点,未命中但占比小

关键 :即使做了 Retrieval,只要检索结果是有限的固定组合(如 Top-3 从 20 个工具中选,共 C(20,3)=1140 种组合),vLLM 的 Prefix Cache 依然能命中部分中间节点。

5. 工程实践建议

场景 推荐方案 缓存命中率 工具覆盖率
高频固定工具(<10 个) 全量传递,不做检索 95%+ 100%
中频分类工具(10-50 个) 分层传递(Hot 全量 + Cold 检索) 70-85% 95%+
低频海量工具(50+ 个) 延迟检索(多轮 Agent)或 Fine-tuned Router 60-80% 90%+
极低延迟要求 静态工具集,牺牲覆盖率 99% 受限

具体实现:双级缓存策略

python 复制代码
def build_prompt(query, user_context):
    # 第一级:静态热工具(缓存友好)
    base_tools = get_hot_tools()
    
    # 第二级:基于用户画像的半静态工具(组内缓存)
    user_segment = get_user_segment(user_context)  # 如 "enterprise_user"
    segment_tools = get_segment_tools(user_segment)
    
    # 第三级:动态检索(接受未命中)
    if needs_dynamic_tools(query):
        dynamic_tools = retrieve_tools(query, top_k=2)
    else:
        dynamic_tools = []
    
    return concat_prompt(base_tools, segment_tools, dynamic_tools, query)

总结

不要二选一,而是分层:

  1. Hot 工具静态化:永远传递,保证 80% 缓存命中
  2. Warm 工具分组化:按业务/用户分群,组内共享缓存
  3. Cold 工具动态化:接受缓存未命中,但控制数量(Top-2/3)和位置(放在序列尾部,减少失效范围)

这样既能控制 Prefill 成本,又能覆盖长尾工具需求。

四、提示词的长度约束

在构建System Prompt的时候,有些内容的长度是动态的,比如SOUL.md、AGENTS.md这 2用户自己编写、Skills Index、MEMORY.md + USER.md等等,长度限制是如何设计的?

System Prompt 构建的完整设计------三分层缓存架构是整个体系的灵魂。


4.1 三层缓存分桶:Stable / Context / Volatile

这是整个 System Prompt 构建最核心的设计决策。Hermes 把 System Prompt 的所有内容按变化频率 分成三个 tier,用 \n\n 拼接:

复制代码
完整 System Prompt =  Stable + "\n\n" + Context + "\n\n" + Volatile
                       ↑                                    ↑
              最稳定的部分                         最容易变化的部分
                       ↓                                    ↓
              缓存命中率最高                         缓存命中率最低

为什么要分桶? 因为上下文压缩时,system prompt 会被重建。重建后,如果大部分内容没变,prefix cache 就能继续命中(只需从变化点开始重建缓存)。如果不分区,哪怕只改了一个 timestamp 小细节,整个 system prompt 的 cache 都要从头重建。

三层各自装什么

Tier 稳定性 装什么 变化原因
Stable 几乎不变 身份(SOUL.md / 默认身份)、帮助指引、执行纪律指令(Tool-use enforcement)、记忆/技能使用指引、编码姿势前缀、阿里模型身份补丁、auto-load skills 只有换 profile、换 model 时才变
Context 跨 session 变 用户传入的 system_message(ephemeral)、项目上下文文件链(AGENTS.md / .hermes.md / CLAUDE.md / .cursorrules)、工作区快照(git probe 结果)、环境探测、Bot Mode 协议、Platform hint cwd 变了(跨 worktree)、换平台、项目文件变了
Volatile 最易变 Skills 索引、MEMORY.md + USER.md、外部 memory provider 块、Plugin sections、Profile 名称行、时间戳行、Runtime Environment heading 每次 compression rebuild、每天换日子、新记忆写入

设计亮点 :时间戳(Conversation started: Monday, October 13, 2026 (America/New_York, UTC-04:00))放在 Volatile 层的最尾部。这样跨天 rebuild 时,只有尾部变了------前面 Stable + Context 的 cache prefix 全部保留。


4.2 动态内容的长度管理策略

SOUL.md / AGENTS.md / .hermes.md / CLAUDE.md:统一的 Context File 截断机制

这四个文件走同一套截断逻辑 ,核心是Head+Tail 截断(中间砍掉,保头保尾):

复制代码
原始内容: [头 70%] [中间 10%] [尾 20%]
截断后:   [头 70%] [截断标记] [尾 20%]
总长度:   max_chars

为什么保头保尾?

  • 头部通常是文件的标题、一级规则清单------模型需要知道"这个文件讲的是什么"
  • 尾部通常是补充说明、特殊情况、FAQ------模型如果查特定场景需要尾部信息
  • 中间通常是展开的细节、长篇示例、重复强调------最容易压缩掉
max_chars 怎么算?------动态预算 + 可配置覆盖
复制代码
max_chars 计算优先级:
  1. 用户在 config.yaml 里显式设置 context_file_max_chars
  2. 动态预算 = context_length × CHARS_PER_TOKEN × 6%
     (6% 是模型窗口里分给 context 文件的固定比例)
  3. Clamp 到 [20,000 floor, 500,000 ceiling]

不同模型的效果:
  GPT-4o (128K ctx, ~2.5 chars/token)
    → 128000 × 2.5 × 6% = 19,200 → 触发 floor → 20K chars

  Claude 3.5 Sonnet (200K ctx)
    → 200000 × 2.5 × 6% = 30,000 → 刚好

  Claude 3 Opus (2M ctx)
    → 2000000 × 2.5 × 6% = 300,000 → 不到 ceiling → 300K chars

  某个 8K 小模型
    → 8000 × 2.5 × 6% = 1,200 → 触发 floor → 20K chars
还有什么保护机制?
机制 说明
5s 读超时 每个文件用 daemon thread 读,超 5s(可 config 覆盖)就跳过。防 iCloud Drive / NFS 冷读卡死整个启动
安全扫描 用 tools/threat_patterns.py 扫描 prompt injection 模式("ignore previous instructions" 等)
SOUL.md 信任豁免 SOUL.md 在用户自己的 HERMES_HOME 里------扫描命中只 WARN 不 BLOCK(因为是用户自己的身份文件,用户写了安全指引不应该丢整个 SOUL.md)
AGENTS.md 严格拦截 项目 checkout 来的 AGENTS.md / .cursorrules 是 BLOCKED------repo owner 可能在里面藏注入攻击
截断警告上报 截断会记录在 ContextVar 里,构建完成后通过 drain_truncation_warnings() 通知用户
忽略的 Markdown 标题 截断时如果中间被砍掉了几个 heading(# ...),会统计出来追加到截断标记里------让模型知道"第 X、Y、Z 节被砍了"
AGENTS.md 的文件发现优先级链
复制代码
从 cwd 向上爬到 git root:
  cwd/AGENTS.md            → 最优先
  cwd/.cursorrules         
  cwd/.hermes.md / HERMES.md
  cwd/CLAUDE.md             
  git-root/AGENTS.md
  git-root/.cursorrules
  ...
  git-root/.hermes.md

每层都有优先级,且每层独立截断。多层拼接在一起。

Skills Index:两层缓存 + 条件过滤

Skills Index 也有动态大小------取决于装了多少 skill、每个 skill 的描述多长。它的策略和 context files 完全不同:

复制代码
Skills Index 构建链路:

磁盘: ~/.hermes/skills/**/SKILL.md (每个 skill 一个目录)
  │
  ├─ _build_skills_manifest()  → 文件签名表 (stat.st_mtime + st_size)
  │     用来判断 "skills 目录有没有变"
  │
  ├─ 查 disk snapshot (.skills_prompt_snapshot.json)
  │     版本号 + manifest 匹配 → 直接用
  │     不匹配 → 全量重新扫描
  │
  ├─ 扫描每个 SKILL.md:
  │     parse frontmatter (YAML 头)
  │     提取: skill_name, category, description, platforms, conditions
  │     过滤掉: 平台不匹配、环境不匹配、requires_toolsets 不满足的
  │     渲染成: "- skill_name: one-line description"
  │
  ├─ 进程内 LRU cache (最多 32 条, key = skills_dir × platform)
  │     多 profile 网关防 thrash
  │
  └─ 输出: 纯文本索引 (N 行 bullet points)

关键设计 :Skills Index 本身没有硬字符上限 ------因为它只是"名字 + 一句话描述",每个 skill 平均 50-100 chars,就算装 200 个也才 20K chars。如果真的膨胀了,截断策略是按 category 降级(Focus 模式下非 coding 类只保留名字、丢掉描述)。

另外,skill 还有条件显示 机制------_skill_should_show() 检查:

  • 这个 skill 要求特定 platform?(session 不匹配就隐藏)
  • 要求特定 toolset?(工具集没有就隐藏)
  • 是某个 toolset 的 fallback?(如果主 toolset 已存在就隐藏)

MEMORY.md + USER.md:记忆的"硬预算"与冻结快照

MEMORY.md 和 USER.md 的大小控制方式,不是截断 ,而是写的时候就限制长度:

复制代码
记忆写入时的约束:
  MEMORY.md: 有硬字符预算(记忆本身就是声明式事实,应该精炼)
  USER.md:  同样是预算约束

记忆里存的是"谁是用户、环境事实、长期约定"------
应该是精炼的 bullet points,不应该是长篇大论

System Prompt 里的呈现方式:
  ## Persistent Memory
  - User prefers concise responses
  - Working directory convention: /Users/lihy/project
  - ...

  ## User Profile  
  - Name: Li
  - Role: Senior Backend Engineer
  - ...

冻结快照机制(这是最关键的设计):

复制代码
第一次 build_system_prompt() 时:
  agent._cached_system_prompt = "完整 system prompt bytes"
  MEMORY.md 内容加载并拼接进去

后续所有 turn:
  直接返回 _cached_system_prompt
  即使 memory provider 后台写了新记忆 → System Prompt 不刷新

什么时候刷新?
  只有 context compression → invalidate_system_prompt()
    → 清 cache → 下一轮重新 build → 新的 MEMORY.md 进去

为什么要冻结? 因为 prefix cache 要求前缀字节稳定。如果每轮都重新读 MEMORY.md(memory provider 可能后台异步更新),每轮的 system prompt 都不一样,cache 全部断掉。


4.3 重建边界

什么时候重新 build_system_prompt()?

触发条件 说明
第一次 turn 必须 build
Context compression 唯一合法的中途重建
Agent resume(从 SessionDB 恢复) 重建,用存下来的 bytes 验证能否命中 cache
profile 切换(多 profile 网关) 不同 profile 的 skills / soul 不同 → 重建
换 model(fallback 链) 某些 model 有特殊 guidance(阿里模型身份补丁等)

重建后会调用 invalidate_system_prompt():

  • 清掉 _cached_system_prompt
  • 清掉 plugin section 的 frozen snapshot(保留上一份当 fail-open fallback)
  • 重新从磁盘加载 MEMORY.md / USER.md(捕获这期间写的新记忆)

4.4 构建总览图

复制代码
build_system_prompt():

Stable tier:
  ├─ [身份] SOUL.md (截断 20K-500K) 或 DEFAULT_AGENT_IDENTITY
  ├─ [帮助指引] HERMES_AGENT_HELP_GUIDANCE (variant 按 skill_view 可用性选)
  ├─ [执行纪律] TASK_COMPLETION_GUIDANCE        ← 有 tool 时
  ├─ [执行纪律] PARALLEL_TOOL_CALL_GUIDANCE     ← 有 tool 时
  ├─ [工具指引] _tool_guidance_block            ← memory/skill/kanban/session_search
  ├─ [转向通道] STEER_CHANNEL_NOTE             ← 有 tool 时
  ├─ [模型特定] TOOL_USE_ENFORCEMENT / GOOGLE_MODEL / OPENAI_MODEL_EXECUTION_GUIDANCE
  ├─ [阿里补丁] 真实 model identity 注入         ← provider=alibaba
  ├─ [固定技能] skills.auto_load 区块 (解析一次)
  ├─ [编码前缀] coding_prefix_parts
  └─ [环境探测] environment_probe + bot_mode + platform_hint

Context tier:
  ├─ [调用方注入] ephemeral_system_prompt (不传就空)
  ├─ [项目上下文] AGENTS.md / .cursorrules / .hermes.md / CLAUDE.md
  │     按 cwd→gitroot 链发现,每层独立截断,安全扫描
  └─ [工作区] git probe 快照 (冻结 pin,rebuild 时复用)

Volatile tier:
  ├─ [技能索引] Skills Index (两层缓存 + 条件过滤,硬无上限但按描述长度自然收敛)
  ├─ [内置记忆] MEMORY.md (冻结快照, 写时预算约束)
  ├─ [内置记忆] USER.md  (冻结快照, 写时预算约束)
  ├─ [外部记忆] memory provider block (插件注入)
  ├─ [Plugin sections] 冻结的插件区块
  ├─ [Profile] Active Hermes profile: xxx
  ├─ [时间戳] Conversation started: ... + 跨天附加行
  └─ [Runtime] > Runtime Environment Heading (环境探测、embedder 提示)

拼接: stable \n\n context \n\n volatile
缓存: agent._cached_system_prompt

4.5 总结:动态内容的处理哲学

内容 动态性 长度控制 安全机制 缓存策略
SOUL.md 用户自己编辑 动态预算 (ctx × 6%, 20K-500K) 扫描只 WARN 不 BLOCK Stable tier,冻结
AGENTS.md 链 checkout / 改动项目 同上,每层独立截断 扫描 BLOCKED Context tier,cwd 变才重建
Skills Index 安装/卸载 skill 自然收敛(只有名字+描述) 平台/环境/toolset 条件过滤 Volatile tier,两层缓存
MEMORY.md 写记忆时更新 写入时硬预算约束 --- Volatile tier,冻结快照
USER.md 同上 同上 --- 同上
时间戳 每天变 固定长度 (~150 chars) --- Volatile tier 最尾部,跨天才多一行

五、字符长度计算

长度限制是具体怎么计算的,模型上下文的长度是配置的,还是调用模型返回的,模型的长度是 token 单位,文件的内容是字符,这 2 者单位不一致,怎么能在一起计算


5.1 Context Length 到底从哪来?------ 五层解析,逐层 fallback

Hermes 有一个精心设计的 context_length 解析链,因为不同 provider 暴露 context length 的方式完全不同,而且很多模型 API 根本不返回这个值。

复制代码
context_length 解析优先级:

Layer 1: 用户在 config.yaml 里手动设置
         model.context_length: 200000
         → 直接用,跳过所有自动探测

Layer 2: Provider endpoint scope (特定 endpoint 的特例覆盖)
         kimi api.kimi.com/coding 上 kimi-k3 = 1_048_576
         nvidia integrate.api.nvidia.com 上 deepseek-v4-pro = 262_144
         → 按 base_url + model 精确匹配

Layer 3: 本地硬编码字典 (DEFAULT_CONTEXT_LENGTHS)
         dict["gpt-4o"] = 128000
         dict["claude-sonnet-4-6"] = 1000000
         → ~200 个条目,最长 key 匹配(specific 优先于 catch-all)

Layer 4: 远程 /models API 探测
         ├─ OpenRouter /api/v1/models → 每个模型带 context_length
         ├─ Ollama /api/show → parameters.num_ctx 或 model_info.context_length
         ├─ LM Studio /api/v1/models → loaded_instances[0].config.context_length
         ├─ vLLM /v1/models → context_length
         ├─ 通用 OpenAI-compatible /v1/models → 扫描 context_length / context_window / max_model_len / ...
         └─ Anthropic: 不探测(硬编码在字典里,API 不返回这个值)

Layer 5: Live API probe (极端情况)
         发一条 prompt,包含 "tell me the first 500 chars of the text I will now quote..."
         然后 send 一段超大文本在 user message 的后面
         模型如果截断了 → 试更小的 context_length
         (Bedrock adapter 用这个方法,因为 Bedrock 的 context 解析非常不透明)

如果全部失败: fallback = 256_000(CONTEXT_PROBE_TIERS[0])

⚠️ 最小值限制: MINIMUM_CONTEXT_LENGTH = 64_000
   任何低于 64K 的模型都会被拒绝(agent init 阶段报错)

为什么不直接让模型 API 返回? 因为:

  • 不是所有 API 都有这个字段(Anthropic 完全不返回)
  • 有些返回的是训练时的 window,不是 runtime window(Ollama 的 GGUF 元信息 vs 用户设的 num_ctx)
  • Codex OAuth 的 context 取决于用户的 entitlement(免费用户可能只有 128K,付费有 400K)
  • 有些是 per-endpoint 的(Kimi K3 在 api.kimi.com/coding 是 1M,在 legacy endpoint 是 256K)

所以 Hermes 才搞了这个五层 fallback 链------能精确就精确,探测不到就兜底。


5.2 CHARS_PER_TOKEN:经验换算常数

python 复制代码
CHARS_PER_TOKEN = 2.5   # 一个经验值,不是精确值

为什么是 2.5? 这是一个保守的经验换算常数,来自多个主流 tokenizer(GPT-4 的 cl100k、Claude 的 o200k、Llama 的 LLaMA-2)在混合文本(英文代码 + 自然语言 + 中文 + emoji)上的平均:

Tokenizer 纯英文 代码 中文 混合平均
cl100k (GPT-4) ~4 chars/token ~2-3 ~1.5 ~2.5
o200k (Claude 3) ~4 ~2-3 ~1.5 ~2.5
LLaMA-2 ~4 ~2 ~1.5 ~2.4
GPT-4o (gpt-4o 中文) ~4 ~2-3 ~1-1.5 ~2.2

2.5 是一个安全下限------实际平均接近 3-4,但用 2.5 换算出来的 char 预算会比实际 tokenizer 更保守,避免因为换算太乐观导致截断不够用。


5.3 完整换算公式

复制代码
context_length = 128_000 tokens    ← 从上面的五层 fallback 链得到

budget_in_chars = context_length × CHARS_PER_TOKEN × 6%
                = 128_000 × 2.5 × 0.06
                = 19,200 chars

floor = 20,000 chars     ← CONTEXT_FILE_MAX_CHARS(硬下限)
ceiling = 500,000 chars  ← _CONTEXT_FILE_DYNAMIC_CEILING(硬上限)

max_chars = clamp(budget_in_chars, floor, ceiling)
          = clamp(19200, 20000, 500000)
          = 20,000 chars    ← 这个模型的 context file 截断上限

6% 是怎么来的? _CONTEXT_FILE_WINDOW_FRACTION = 0.06------这是模型窗口里分给"项目上下文文件(AGENTS.md 等)"的固定比例。System prompt 里还有很多其他部分(identity、guidance、skills、memory...)也要占 token,所以 context files 只能占 6%。

这个公式对不同模型的效果:

模型 context_length × 2.5 × 6% clamp 到 实际值
GPT-4o mini 128K 19,200 ≥ floor 20K chars
Claude Sonnet 4 200K 30,000 都在范围内 30K chars
Claude Opus 5 1,000K 150,000 都在范围内 150K chars
GPT-6 Astra 1,050K 157,500 都在范围内 157K chars
某个小模型 64K (刚好 MINIMUM) 9,600 ≥ floor 20K chars

设计要点:小模型(8K、32K、64K)全部被 floor 到 20K------因为 context files 的最小合理长度就是一个 AGENTS.md 加几个项目说明,20K chars 刚好够用。再少就真的丢信息了。

超大模型(Opus 5 1M、GPT-6 1.05M)能分到 150K+ chars------这意味着它们可以塞下非常长的项目文档链(多层 AGENTS.md + 大 .cursorrules)。


5.4 用户能不能覆盖?------可以

yaml 复制代码
# config.yaml
model:
  context_length: 200000        # 覆盖自动探测

context_file_max_chars: 50000   # 覆盖动态预算计算

显式设置就跳过自动计算,直接用用户给的值。


5.5 这个值在什么时候被用到?

复制代码
init_agent() / build_system_prompt() 阶段:
  ├─ load_soul_md(context_length)
  │     → _truncate_content(..., max_chars = 20000)
  │
  ├─ discover_context_files(cwd, context_length)
  │     └─ 每个 context file 独立走 _truncate_content
  │
  └─ 最终 system prompt 里每个 context file 区块都 ≤ max_chars

context compression 阶段:
  ├─ ContextCompressor 也要知道 context_length
  │     → 决定什么时候启动压缩(比如 85% 阈值)
  │     → 决定压缩后的目标大小(50% context_length)
  │     → estimate_messages_tokens_rough() 用 CHARS_PER_TOKEN 换算 messages 大小
  │
  └─ 如果用辅助压缩模型,context_length 也决定辅助模型的截断预算

Ollama num_ctx clamp:
  └─ 如果本地 Ollama 的 num_ctx 设得比探测到的 context_length 小
     → 按 num_ctx 约束,不能发超出 Ollama 实际窗口的请求

5.6 总结:整个流程串起来

复制代码
config.yaml model.context_length? ──是──→ 直接用
        │否
        ▼
provider endpoint scope 精确匹配? ──是──→ 用 endpoint-specific 值
        │否
        ▼
DEFAULT_CONTEXT_LENGTHS 字典查找? ──是──→ 用最长 key 匹配的值
        │否
        ▼
远程 /models API 探测? ──有返回──→ 用探测结果
        │没返回
        ▼
Live API probe (Bedrock 专用)? ──成功──→ 用 probe 结果
        │失败
        ▼
fallback = 256,000 tokens
        │
        ▼
clamp(MINIMUM = 64,000) → 得到 agent.context_length (tokens)
        │
        ▼
context_file_max_chars = clamp(context_length × 2.5 × 0.06, 20K, 500K)
        │
        ▼
SOUL.md / AGENTS.md / .cursorrules / .hermes.md
  → read full text
  → Head(70%) + Tail(20%) truncate to max_chars
  → 安全扫描 (inject patterns)
  → 拼进 system prompt

核心洞察 :Hermes 从来不会"精确"计算 token 数------它用 CHARS_PER_TOKEN = 2.5 这个经验常数做粗略估算,然后靠 floor/ceiling clamp 保证不会太激进也不会太保守。这种做法在工程上足够好,因为:

  1. Tokenizer 的精确换算在不同 provider 上差异很大,你没法精确知道
  2. Context files 占的是 system prompt 的一部分,不是 messages 链的全部
  3. 真正的 context overflow 由 API 返回错误来兜底(超出窗口会报错,Hermes 有 recovery 逻辑处理)
  4. 6% fraction + clamp 机制已经足够"够大但不浪费"