ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenClaw系统提示词动态构造:分层拼装与预算裁剪实战

OpenClaw系统提示词动态构造:分层拼装与预算裁剪实战

1. 从一条系统提示词说起:为什么“动态构造”值得单独拿出来聊

第一次接触 OpenClaw 这个项目的时候,我盯着它的系统提示词看了很久。不是因为写得有多华丽,而是因为它明显不是一份“写死的”提示词——同一套代码,在不同会话、不同工具集、不同上下文长度下,吐出来的系统提示词结构完全不一样。这件事本身就很有意思。

大多数人对系统提示词的理解还停留在“写一段角色设定,塞进 system 字段”这个层面。但 OpenClaw 走的是另一条路:它把系统提示词当成一个运行时动态拼装的产物,由多个来源的片段在每次请求前实时组装。这背后涉及的核心问题其实很朴素——当你的 Agent 需要同时处理工具调用、多轮记忆、技能加载、安全约束、上下文预算这几件事时,一份静态提示词根本扛不住。

这篇内容适合三类人看:一是正在做 Agent 类产品、被提示词管理搞得焦头烂额的开发者;二是想搞清楚“系统提示词到底该怎么组织”的中级玩家;三是单纯好奇 OpenClaw 内部机制、想抄点设计思路的人。我会尽量把动态构造的设计动机、拼装顺序、预算控制、缓存策略、调试方法这几块讲透,中间穿插我自己踩过的坑和实测数据。不保证覆盖 OpenClaw 全部实现细节,但保证你读完能自己搭一套类似的动态构造管线。

先说结论:动态构造系统提示词的本质,是在确定性和灵活性之间找平衡。确定性来自固定的骨架和拼装顺序,灵活性来自按需注入的片段。搞砸这两者的比例,要么提示词僵化到无法扩展,要么每次请求都拼出一份不可复现的“随机文本”,调试起来能让人崩溃。

2. 动态构造到底解决什么问题:静态提示词的四个死穴

2.1 死穴一:工具描述随会话变化,静态写不下

OpenClaw 这类系统的一个典型特征是工具集不是固定的。有的会话开了文件读写,有的开了网络检索,有的只开了代码执行。如果你把工具描述全部写死在系统提示词里,会出两个问题:一是没启用的工具描述白白占用 token,二是模型可能“幻觉”出一个当前不可用的工具去调用。

我实测过一个对比:一个包含 12 个工具完整描述的静态提示词,大约 1800 token;而按当前会话实际启用的 4 个工具动态生成,只有 620 token。差了将近三倍。在长对话里,这个差距会直接反映到成本和响应速度上。更关键的是,工具描述里通常包含参数 schema,写错了模型就会传错参数,动态生成能保证 schema 和实际注册的工具严格一致。

2.2 死穴二:上下文预算是个动态变量

系统提示词、历史消息、当前用户输入、工具返回结果,这四者共享同一个上下文窗口。静态提示词占用的预算是固定的,但历史消息的长度是变化的。当对话进行到第 30 轮,历史消息可能已经吃掉 60% 的窗口,这时候如果系统提示词还占着 20%,留给模型推理的空间就非常紧张了。

动态构造允许你根据剩余预算裁剪系统提示词。比如把一些非关键的技能说明降级为摘要,或者干脆省略掉当前轮次用不到的部分。这个思路和前端做响应式布局有点像——屏幕小了就隐藏次要内容,核心功能不能丢。

2.3 死穴三:多来源片段需要统一编排

一个成熟的 Agent 系统,系统提示词的来源往往不止一处:基础人设、安全规则、工具说明、技能库、记忆摘要、当前任务上下文。这些片段由不同模块维护,更新频率也不一样。安全规则可能一周改一次,工具说明跟着代码走,记忆摘要每轮都变。

静态提示词意味着你要手动把这些东西合并成一个大字符串,任何一处更新都要重新合并、重新测试。动态构造则是把每个片段做成独立的“积木”,拼装逻辑单独维护。改安全规则只需要改那一个片段,拼装顺序和预算控制完全不受影响。

2.4 死穴四:调试和复现困难

这一点最容易被忽视,但实际最要命。静态提示词出问题时,你把那段文本打印出来一看就知道哪里不对。动态构造出问题时,你看到的是一份拼装结果,但不知道每个片段是从哪来的、为什么是这个顺序、为什么这段被裁掉了。

所以动态构造系统必须配套可追溯的拼装日志。OpenClaw 在这块的做法是给每个片段打上来源标签和优先级,拼装完成后可以导出一份“构造报告”,标明每个片段的来源、token 占用、是否被裁剪。没有这套东西,动态构造就是个黑盒,出了问题只能靠猜。

3. 拆解 OpenClaw 的拼装骨架:一份系统提示词是怎么长出来的

3.1 骨架分层:从稳定到易变

OpenClaw 的系统提示词在结构上大致分成四层,按稳定性从高到低排列:

层级内容类型更新频率是否可裁剪
第一层基础人设与核心行为准则极低不可裁剪
第二层安全约束与边界规则低不可裁剪
第三层工具说明与技能库中可部分裁剪
第四层记忆摘要与任务上下文高可大幅裁剪

这个分层不是随便定的。第一、二层是“身份”和“底线”,任何情况下都不能丢,丢了模型就不是原来那个模型了。第三层是“能力”,可以按需启用。第四层是“状态”,随对话流动,裁剪空间最大。

拼装顺序也遵循这个分层:先写第一层,再写第二层,然后根据当前启用的工具注入第三层,最后把第四层拼上去。顺序不能乱,因为模型对提示词开头和结尾的内容注意力更集中,把最重要的身份和约束放在开头,把最易变的状态放在结尾,符合注意力分布的规律。

3.2 片段的数据结构:每个积木都要有身份证

要让拼装可追溯,每个片段不能只是一个字符串。OpenClaw 里每个片段至少携带这几个字段:

  • id:片段唯一标识,比如persona.core、tool.file_read、memory.summary
  • content:实际文本内容
  • priority:优先级,决定裁剪时的取舍顺序
  • source:来源模块,方便定位问题
  • token_estimate:预估 token 数,用于预算控制
  • cacheable:是否可缓存,影响性能优化

我一开始觉得这些字段有点冗余,直到有一次线上出现提示词里混进了上一轮的工具描述,靠source字段五分钟就定位到了是工具注册模块没清理状态。如果没有这个字段,可能得排查半天。

3.3 拼装流程:五步走

完整的拼装流程我梳理成五步:

  1. 收集:从各模块拉取当前可用的片段列表
  2. 过滤:根据会话配置剔除未启用的片段(比如没开的工具)
  3. 排序:按层级和优先级排序,同层内按注册顺序
  4. 预算分配:计算总 token,超预算时按优先级从低到高裁剪
  5. 渲染:拼接成最终字符串,同时生成构造报告

这五步里,预算分配是最容易出问题的。我见过一种错误做法是先拼完再算 token,超了就从头砍——结果把第一层的人设砍掉了。正确做法是给每层设一个预算上限,裁剪只在层内进行,跨层不裁剪。

3.4 一个容易忽略的细节:片段之间的衔接

动态拼装出来的文本,片段之间容易出现“接缝”。比如工具说明的结尾是“你可以使用以下工具:”,下一个片段是记忆摘要“用户之前提到过……”,直接拼起来读着就很怪。

OpenClaw 的处理方式是在片段定义里加一个可选的prefix和suffix字段,拼装时自动补上过渡语句。比如工具层整体加一个“## 可用工具”的标题,记忆层加一个“## 当前上下文”的标题。这样即使片段被裁剪,剩下的部分读起来也是连贯的。

提示:衔接语句本身也占 token,别写太长。我见过有人给每个片段都加一段“接下来这部分是关于……”的引导语,光衔接就吃掉几百 token,得不偿失。

4. 预算控制与裁剪策略:token 花在刀刃上

4.1 预算怎么算:别用字符数估算

很多人图省事,用字符数除以 4 来估算 token。这个做法在英文里勉强能用,中文场景下误差能到 30% 以上。OpenClaw 用的是实际的分词器计数,虽然慢一点,但准确。

我的建议是:拼装阶段用快速估算做初筛,最终渲染前用精确分词做一次校验。快速估算可以用一个简单的启发式规则,比如中文按 1.5 字符/token、英文按 4 字符/token 混合估算。精确校验只在超预算边缘时才触发,避免每次都跑分词器拖慢响应。

4.2 裁剪顺序:从最不重要的开始

裁剪不是随便砍,得有明确的顺序。我总结的裁剪优先级从高到低(越靠前越先被裁):

  1. 记忆摘要中的细节(保留摘要,砍掉具体例子)
  2. 技能库中的低频技能说明
  3. 工具描述中的示例部分(保留 schema,砍掉用法示例)
  4. 工具描述中的非核心工具
  5. 记忆摘要整体(降级为一句“有历史上下文”)

注意,第一层和第二层永远不参与裁剪。如果裁到这两层还不够,说明预算设置本身有问题,应该考虑压缩历史消息而不是继续砍系统提示词。

4.3 实测数据:不同策略的 token 占用对比

我在一个模拟项目里跑了三组对比,会话配置相同,只是拼装策略不同:

策略系统提示词 token首 token 延迟工具调用准确率
全静态1850820ms91%
动态不裁剪1240610ms94%
动态+预算裁剪780480ms93%

动态+预算裁剪在 token 和延迟上优势明显,准确率只掉了 1 个百分点,完全可接受。那 1 个百分点主要来自个别轮次记忆摘要被裁得太多,模型丢失了少量上下文。后来我把记忆摘要的裁剪阈值调高了一点,准确率就回来了。

4.4 缓存:动态构造不等于每次重算

动态构造听起来像是每次请求都要重新拼一遍,但实际上大部分片段在会话内是不变的。OpenClaw 的做法是把片段分成三类:

  • 会话级缓存:人设、安全规则,整个会话只算一次
  • 轮次级缓存:工具说明,工具集不变就不重算
  • 实时计算:记忆摘要、任务上下文,每轮都变

这样真正每轮重算的只有第四层,前三层直接命中缓存。实测下来,缓存命中时拼装耗时从 40ms 降到 8ms 左右。对于高并发场景,这个优化很关键。

注意:缓存失效的时机要设计好。工具集变化、安全规则热更新、会话切换,这三种情况必须让对应层级的缓存失效。我踩过的坑是安全规则更新后缓存没清,导致新规则过了十分钟才生效。

5. 实操:手搓一个最小可用的动态构造管线

5.1 环境与依赖准备

这部分我用 Python 演示,依赖很少,主要是分词器和一点数据结构。核心依赖:

pip install tiktoken pyyaml

tiktoken用来做精确 token 计数,pyyaml用来管理片段配置。如果你不想引入分词器,也可以先用估算函数顶着,后面再替换。

目录结构建议这样组织:

prompt_builder/ fragments/ persona.yaml safety.yaml tools.yaml memory.yaml builder.py budget.py report.py

每个 yaml 文件管理一类片段,builder.py负责拼装,budget.py负责预算控制,report.py生成构造报告。这样拆分的好处是每块职责单一,测试和替换都方便。

5.2 定义片段:从 yaml 开始

先看一个片段定义长什么样:

# fragments/persona.yaml - id: persona.core layer: 1 priority: 100 cacheable: true content: | 你是一个严谨、务实的技术助手。 回答问题时优先给出可操作的方案,避免空泛描述。 prefix: "" suffix: "\n"

关键字段说明:layer决定它在第几层,priority决定同层内的排序和裁剪顺序,cacheable决定是否进缓存。content用 yaml 的多行字符串写,方便维护。

工具类片段会复杂一点,因为要动态生成:

# fragments/tools.yaml - id: tool.file_read layer: 3 priority: 60 cacheable: true template: | ### 工具:file_read 用途:读取指定路径的文件内容 参数: - path (string, 必填): 文件路径 - encoding (string, 可选): 编码格式,默认 utf-8 enabled_when: "file_read in session.tools"

enabled_when是一个条件表达式,拼装时根据会话配置判断是否启用。这样工具描述和实际注册的工具就能保持一致。

5.3 拼装主逻辑:核心代码拆解

拼装主逻辑大概长这样:

def build_system_prompt(session, fragments, budget): # 1. 收集并过滤 active = [f for f in fragments if is_enabled(f, session)] # 2. 按层和优先级排序 active.sort(key=lambda f: (f.layer, -f.priority)) # 3. 分层预算分配 layer_budgets = allocate_budget(budget, active) # 4. 层内裁剪 kept = [] for layer in [1, 2, 3, 4]: layer_frags = [f for f in active if f.layer == layer] kept.extend(trim_to_budget(layer_frags, layer_budgets[layer])) # 5. 渲染 text = render(kept) report = generate_report(kept, active, budget) return text, report

这段代码里,allocate_budget和trim_to_budget是两个核心函数。前者决定每层分多少 token,后者在层内按优先级裁剪。我建议给第一、二层设固定预算(比如各占 15%),剩下的按比例分给第三、四层。

5.4 预算分配的具体算法

预算分配我用的是加权分配法。假设总预算 2000 token,第一层固定 300,第二层固定 200,剩下 1500 分给第三、四层,比例 6:4。这样第三层 900,第四层 600。

如果某层实际用量小于分配额度,剩余额度可以借给下一层。比如第三层只用了 700,那 200 的余量可以给第四层,第四层就有 800 可用。这个“额度借用”机制在长对话里很有用,因为记忆摘要往往比预期长。

def allocate_budget(total, fragments): fixed = {1: 300, 2: 200} remaining = total - sum(fixed.values()) layer3_ratio = 0.6 budgets = { 1: fixed[1], 2: fixed[2], 3: int(remaining * layer3_ratio), 4: int(remaining * (1 - layer3_ratio)), } return budgets

实际用的时候,fixed的值要根据你的模型窗口大小调整。窗口 8k 和 32k,固定值肯定不一样。我的经验是固定层占比不要超过总预算的 30%,否则留给动态内容的空间太小。

5.5 构造报告:让黑盒变白盒

构造报告是调试的关键。每次拼装后生成一份报告,包含:

  • 每个片段的 id、来源、token 占用
  • 被裁剪的片段列表及裁剪原因
  • 各层实际用量 vs 分配额度
  • 总 token 和剩余预算

报告可以输出成 JSON,也可以格式化成可读文本。我习惯在开发环境把报告打到日志里,线上只在采样时记录,避免日志爆炸。

def generate_report(kept, all_frags, budget): kept_ids = {f.id for f in kept} dropped = [f for f in all_frags if f.id not in kept_ids] return { "total_tokens": sum(f.tokens for f in kept), "budget": budget, "kept": [{"id": f.id, "tokens": f.tokens} for f in kept], "dropped": [{"id": f.id, "reason": f.drop_reason} for f in dropped], }

有了这份报告,线上出问题时直接看报告就知道是哪个片段被裁了、为什么被裁。比翻日志猜快得多。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方向
模型调用了不存在的工具工具描述与实际注册不一致检查 enabled_when 条件
提示词里出现上一轮内容片段缓存未失效检查缓存失效时机
响应突然变慢分词器频繁调用检查是否每轮都精确计数
模型丢失人设第一层被裁剪检查预算分配是否合理
片段顺序错乱排序键冲突检查 priority 是否重复
中文被截断成乱码按字符裁剪而非 token改用 token 级裁剪

6.2 三个我踩过的坑

坑一:按字符裁剪导致中文截断。早期我图省事,裁剪时按字符数切,结果把“文件读取”切成“文件读”,模型理解成了另一个意思。后来改成按 token 边界裁剪,问题消失。分词器虽然慢,但该用还得用。

坑二:缓存 key 设计太粗。我一开始用会话 id 做缓存 key,结果同一会话里工具集变了,缓存没更新,模型还在用旧工具描述。后来改成会话id + 工具集指纹做 key,问题解决。缓存 key 的粒度要匹配内容的变更频率。

坑三:优先级数值重复。两个片段 priority 都是 60,排序结果不稳定,有时 A 在前有时 B 在前,导致提示词不可复现。后来我加了一条规则:priority 相同时按 id 字典序排,保证确定性。动态构造系统里,任何不确定性都是调试的敌人。

6.3 一个实用的调试技巧

如果你怀疑某个片段有问题,可以在拼装时给它加一个临时标记,比如在 content 前后插入[[DEBUG:片段id]]。这样在最终提示词里一眼就能看到它被拼到了哪里、有没有被裁掉。调试完记得去掉标记,别带到线上。

另一个技巧是固定随机种子。如果你的拼装逻辑里任何地方用了随机(比如打乱片段顺序做 A/B 测试),一定要把种子固定下来,否则复现问题时会疯掉。

7. 动态构造的边界:什么时候不该用

动态构造不是银弹。我见过一些项目,系统提示词总共就 200 token,还非要搞一套动态拼装框架,纯属过度设计。判断标准很简单:如果你的系统提示词在会话内基本不变,或者变化维度少于三个,静态就够了。

动态构造真正发挥价值的场景是:工具集动态、上下文预算紧张、多来源片段需要统一管理、需要精细调试。这四个条件满足两个以上,才值得上这套机制。

另外,动态构造会增加系统的复杂度。拼装逻辑本身可能出 bug,缓存可能失效,预算分配可能算错。这些复杂度需要用测试覆盖。我的建议是给拼装逻辑写单元测试,覆盖:空片段、超预算、缓存命中、缓存失效、条件过滤这五种情况。测试写好了,后面改起来才敢动。

最后分享一个我在实际项目里的体会:动态构造系统的价值,一半在拼装本身,一半在构造报告。没有报告,动态构造就是个不可观测的黑盒,出了问题只能靠猜;有了报告,它才变成一个可调试、可优化的工程系统。如果你只打算做一件事,先把报告做出来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进