人格模板改了没生效,这种事先别急着怀疑模型。我在几个不同类型的智能体项目里都撞过同一堵墙:agents-templates里的persona.md明明改了措辞,跑起来语气纹丝不动;换个 agent 名字,工具说明又莫名其妙消失。排查到最后,问题几乎都不在模型侧,而在系统提示词的组装管道上——人格模板只是原料,真正决定输出的是那条从agents-templates一路走到最终系统提示词的回落链,以及组装器把各段拼起来时的顺序与裁决规则。
这篇内容就围绕 OpenHanako 这套人格模板体系拆开讲:agents-templates目录下到底该放什么、回落链在目录级和字段级分别怎么走、组装器按什么顺序拼段、变量占位符在哪一步求值、以及我实测中反复踩到的几类坑。适合已经在用或准备接手这类多 agent 工程的人看,也适合只是被"人格不生效"折磨过的同学——哪怕你用的不是 OpenHanako,这套回落与组装的心智模型在绝大多数提示词工程里都通用。
1. 先搞清楚 hanako 到底组装了什么:一份提示词的来源拆解
1.1 从一次"人格不生效"的现场说起
最早的现场很朴素:我在agents-templates/下新建了一个 agent 目录,写了一份自认为很讲究的persona.md,里面规定了说话节奏、称呼习惯、遇到不确定信息时的表达方式。配置改完,重启,对话——语气跟默认助手一模一样,一个字都没变。
当时第一反应是模板没被读进去。于是加日志,发现文件确实读了,内容也确实进了内存。第二个怀疑是拼接被截断,打印最终提示词一看,长度对得上,persona那段文本在里面躺着,位置还挺靠前。第三个怀疑才是真凶:那段人格描述被放在了系统提示词的最前面,而后面紧跟的一大段"能力说明 + 工具清单 + 输出格式约束"体量是它的五六倍,模型的行为更像被后半段牵着走。
这件事给了我第一个结论:人格模板写得好不好是一回事,它在最终系统提示词里占多少"话语权"是另一回事。而话语权由两个变量决定——位置和体量。位置靠前不等于有效,体量太小则直接被淹没。这也是为什么我一直建议把"组装后的最终提示词"当成一个必须能随时打印、随时 diff 的产物,而不是一个黑盒。
顺带说一句,OpenHanako 里把一个 agent 的完整定义拆成好几份文件,这个设计本身是对的:agent.yaml管元信息和能力声明,persona.md管人格语气,system.md管任务框架,skills/管可插拔的技能片段。拆开的好处是每份文件职责单一,改语气不用动工具配置;坏处是你得知道它们最终是怎么被缝在一起的,否则改任何一份都像在盲盒里投币。
1.2 四层来源与各自的职责边界
把 OpenHanako 的提示词来源按"谁写的、改的频率、谁负责"分成四层,后面所有的回落和组装讨论都能挂在这四层上。这个划分是我按常见工程实践归纳的,不同版本细节会有出入,但层次关系基本稳定。
| 层级 | 典型位置 | 内容 | 变更频率 | 谁维护 |
|---|---|---|---|---|
| L1 内置基线 | 代码内置常量 | 最低限度身份声明、安全边界、输出基本格式 | 几乎不变 | 框架维护方 |
| L2 模板库 | agents-templates/<agent>/ | 人格、任务框架、技能片段、示例 | 中 | 项目组 |
| L3 项目覆盖 | 项目根目录的覆盖配置 | 针对具体业务调整的字段 | 高 | 业务方 |
| L4 运行时注入 | 每次请求动态生成 | 当前时间、用户偏好、本轮任务指令 | 每轮都变 | 程序 |
这四层里,L1 到 L3 属于"静态层",理论上可以在加载时渲染完并缓存;L4 属于"动态层",每轮必须重算。把这两类混在一起是绝大多数缓存问题的根源,后面第 4 节会专门讲。
职责边界上我有一条硬规矩:L1 只放"绝对不能丢"的东西,比如身份声明和安全边界;人格和语气一律不许写进 L1,否则所有 agent 都会被同一个腔调污染,你改 L2 也压不住它。反过来,L2 里也不该出现"本轮要做什么"这类指令,那是 L4 的地盘。我曾经在一个项目的 L2 模板里看到写死的"请优先处理工单类请求",结果那个 agent 后来被复用到别的场景,每天都在莫名其妙地往工单方向带——这就是层级越权。
还有一层容易被忽略的:同一层内部的多个文件也有先后。persona.md和system.md谁先谁后,不是拍脑袋决定的。我的做法是把"越靠近身份定义的越靠前",也就是 身份 → 人格 → 能力 → 知识 → 协议 → 上下文 → 任务。理由很直接:模型读长文本时的注意力分布不均匀,靠前的段落对"我是谁"的塑造更强,而靠近末尾的段落对"这一轮做什么"的约束更强。你要是把任务指令放最前面、人格放最后,那就等于让人格去当脚注。
2. 回落链的真实形状:不是一条线,而是一棵逐级裁剪的树
2.1 目录级回落:agents-templates 的查找顺序
很多人把"回落链"理解成一条直线:先找 A,找不到找 B,再找不到找 C。实际用起来它更像一棵树,因为回落发生在两个维度上——目录维度和字段维度。先讲目录维度。
OpenHanako 加载某个 agent 时,大致按这个顺序去找它的模板目录,命中即停但会继续向上合并:
- 请求里显式指定的 agent 标识对应的目录(比如
agents-templates/<agent_id>/) - 该 agent 在
agent.yaml里声明的extends父级目录 - 项目级模板库根目录
- 用户级全局模板目录
- 内置的
_base目录
这个顺序背后的逻辑是"越具体越优先"。显式指定的最具体,_base最泛化,中间层的存在是为了让一组 agent 共享公共部分。举个实际例子:我要做三个面向不同场景的 agent,它们的输出格式约束完全一样,人格完全不一样。做法是抽一个_shared_format父级,三个 agent 都extends它,各自只写persona.md。这样改格式只改一处,三个 agent 同时生效。
但这里有个陷阱,也是我第一次搭这套结构时栽的跟头:目录级回落命中的层数越多,组装出来的提示词越难预测。三层继承叠下来,你打开任何一个子目录都看不到全貌,得在心里做一次合并。所以我现在给自己定的上限是两层继承(_base+ 一个业务父级),超过就往平铺方向重构——宁可复制粘贴,也不要三层以上的继承链。可读性比 DRY 重要,尤其是在提示词这种东西上,因为它的"正确性"很难靠单测保住。
提示:如果你的
extends指向的目录名写错了,很多实现不会报错,而是静默跳过这一层继续往上找。表现就是"父级配置改了没生效"。加载日志里务必把实际命中的目录链打出来。
2.2 字段级回落:缺字段和空字段完全是两回事
目录级解决"去哪儿找文件",字段级解决"文件里少了东西怎么办"。这里是坑最多的地方,因为三种状态在配置文件里长得太像,语义却完全不同:
| 写法 | 语义 | 回落行为 |
|---|---|---|
| 字段不存在 | 未定义 | 继续向父级 / 默认值回落 |
field: null | 显式声明为空 | 通常不回落到父级,视为"清空" |
field: "" | 显式空字符串 | 取决于实现,很多地方会被当成"无值"从而回落 |
field: [] | 显式空数组 | 通常覆盖父级数组为空,不回落到父级 |
第二行和第三行的区别最有价值。null是"我明确要清空父级给我的东西",""在很多实现里会被or短路成假值,从而触发回落——你以为清空了,实际父级的配置又被捞回来了。我在一个项目里想把父级配的一长串禁用词清掉,写了deny_words: "",结果那串词一直在生效,排查了半天才发现应该写deny_words: null或者干脆deny_words: []。
所以字段级的回落规则,我会在agent.yaml的注释里明确标出来,写成给人看的一句话约定:想清空用null或空数组,想回落就删掉这个键。这个小约定省下来的排查时间比它看起来多得多。
另一个字段级细节是数组合并策略。父级给了三个技能片段,子级给了两个,最终是取并集(五个)、取子级(两个)、还是子级覆盖同名项?OpenHanako 这类框架通常支持在agent.yaml里对每个数组字段声明合并方式,比如merge: append/merge: replace/merge: dedupe_by_name。我自己的默认选择是dedupe_by_name:按名字去重,子级的同名项覆盖父级。理由是技能片段通常有唯一名字(name: code_review),按名字覆盖符合直觉,也不会因为重复引入导致同一段技能说明在提示词里出现两遍——重复出现不只是浪费 token,还会让模型误以为这个能力被"强调"了。
2.3 回落链的终止条件与最后一层兜底
任何回落链都必须有终点,否则缺失配置会一路穿到底变成运行时异常。OpenHanako 里终点是内置的_base,它必须满足一个条件:不管你怎么删,它都能组装出一份语法完整、语义最小可用的提示词。
我给自己项目定的_base内容非常克制,只有三块:一句身份声明、一句能力边界("不确定时说明不确定,不编造")、一段输出格式的最小约束。人格、语气、领域知识全部不放。这样做的好处是,当某个 agent 的模板目录整个丢失时,系统不会直接崩溃,而是退化成一个"没有性格但规矩的助手"——退化比崩溃好,尤其在线上。
这里还有个容易被忽略的终止条件:回落链要有环检测。extends是手写的字符串,A 继承 B、B 继承 A 这种事儿,人在赶工时真的干得出来。带环的继承如果实现里没有检测,就是死循环或者栈溢出;如果实现里做了"已访问集合",会静默停在某一层,表现就是"某个字段忽有忽无"。我在组装器里加了一个简单的路径记录,每次进入一层就记下来,重复进入直接抛错并打印完整链路,这个成本极低,收益极高。
3. 组装器的拼接顺序:谁先谁后决定了谁说了算
3.1 段的排序规则与冲突裁决
模板齐了,接下来是把它们拼成一份系统提示词。这一步的排序规则直接决定最终行为。我按"离身份越近越靠前、离动作越近越靠后"的原则排,OpenHanako 的默认组装顺序也基本是这个形状:
- 身份头:agent 叫什么、是什么、不是什么(最短,两三句)
- 人格与语气:说话方式、称呼、情绪基调、禁止的表达习惯
- 能力与工具:能用什么、什么时候用、调用前要确认什么
- 领域知识与参考资料:静态知识片段、术语表、示例问答
- 交互协议:输出格式、结构化要求、不确定性如何表达
- 运行时上下文:当前时间、用户偏好、会话状态摘要
- 本轮任务:这一轮具体要干什么
排序不是洁癖,是有实际后果的。我做过一次对照:把"交互协议"从第 5 位提到第 2 位,同一个 agent 在输出格式上的合规率明显上升,但人格表达明显变弱——因为格式约束太靠前,把语气的空间挤掉了。这不是模型"不听话",是你把最强势的位置给了格式。位置就是权重,这句话在提示词工程里比在任何地方都成立。
冲突裁决是另一半。两段内容打架时谁赢?我的规则是三条,按优先级:
- 显式优于隐式。写了"不要用感叹号"就压过人格段里的"语气可以活泼"。
- 具体优于泛化。针对某类输入的专门约束,压过全局的通用约束。
- 后置禁令优于前置许可。这是经验法则,长提示词里靠近末尾的否定约束通常更容易被遵守。
第三条值得展开。很多人把"不要做什么"写在前面的身份段里,然后在末尾放一大堆任务描述,结果是禁令被冲淡。更好的做法是把最关键的禁令放在紧邻任务指令之前,让它和本轮动作贴在一起。我现在写模板,人格段只写"是什么",禁令单独抽成一个constraints.md,固定挂在第 5、6 位之间。
3.2 变量占位符的求值时机
模板里必然有占位符:{{agent.name}}、{{user.display_name}}、{{now}}、{{session.summary}}。这些什么时候被替换,决定了你的缓存能不能用。
我的做法是两阶段渲染,这也是我在几个项目里验证下来最省心的方案:
- 静态阶段(加载时,每个 agent 一次):只替换那些在 agent 生命周期内不变的变量,比如
{{agent.name}}、{{agent.version}}、{{persona.tone}}。渲染结果直接缓存,key 用agent_id + agent_version + 模板文件内容 hash。 - 动态阶段(每轮请求):替换
{{now}}、{{user.*}}、{{session.*}}、{{task}}这类每轮都变的变量,并把静态阶段的结果作为前缀拼上去。
为什么必须这么分?因为如果把{{now}}丢进静态阶段,缓存 key 里不含时间,你会得到一份"时间冻结"的提示词——用户在半夜对话,系统还以为是早上。反过来,如果把{{agent.name}}丢进动态阶段,每轮都重算,缓存命中率归零,长模板的渲染开销会明显变成延迟。
这里有个实现上的细节:动态阶段不要做全文重新渲染,只做尾部拼接。也就是静态部分缓存成一个不可变的字符串,动态部分单独渲染成一段,最后static_prefix + dynamic_suffix。这样做的好处是静态部分的 token 前缀完全稳定,能吃到服务端的提示词缓存;如果你每轮都把动态变量插到中间,前缀就变了,缓存全废。这一条我在真实项目里量过,改动前后的首 token 延迟差异是可以感知的。
# 组装器骨架(Python 伪代码,示意流程,非框架源码) class PromptAssembler: def __init__(self, loader, renderer, cache): self.loader = loader # 负责走回落链拿模板 self.renderer = renderer # 负责占位符替换 self.cache = cache def build_static(self, agent_id): tpl = self.loader.load(agent_id) # 目录级 + 字段级回落都在这里完成 key = f"{agent_id}:{tpl.version}:{tpl.content_hash}" hit = self.cache.get(key) if hit: return hit sections = self._order_sections(tpl) # 按 3.1 的顺序排序 text = "\n\n".join( self.renderer.render(s, scope="static") for s in sections ) self.cache.set(key, text) return text def build(self, agent_id, ctx): prefix = self.build_static(agent_id) suffix = self.renderer.render( self.loader.dynamic_template(agent_id), scope="dynamic", ctx=ctx ) return prefix + "\n\n" + suffix代码里_order_sections是最值得写单测的地方:给定一个模板对象,断言输出段的顺序和预期完全一致。这个测试写起来两分钟,能在后续每次调整组装逻辑时救你一次。
3.3 一个最小可跑的组装器
上面是骨架,真正跑起来还要补三件事,这三件事不做,组装器就是个玩具。
第一件是分隔符。段与段之间必须有明确的分隔,我用---或者带标题的二级结构(## 人格、## 能力),而不是简单的空行。原因很实际:空行在长文本里不构成边界信号,模型容易把两段读成一段,于是能力说明被当成人格的一部分来"表演"。带标题的分隔还有个额外好处,调试时 grep 就能定位段落在不在。
第二件是段落信用度标记。给每个段打一个priority字段,高优先级的段在超长时优先保留,低优先级的段先被裁掉。裁剪顺序我一般定成:示例问答 → 领域参考 → 技能细节 → 交互协议 → 人格 → 身份。身份和协议永远不裁,这两块没了整个 agent 就散了。token 预算紧张的时候,这个裁剪顺序比无脑截断靠谱得多。
第三件是版本指纹。每次组装完成,计算一个最终提示词的 hash,写进日志和响应元数据。线上出现"今天人格怪怪的"这类问题时,第一件事就是比对两天的 hash。hash 变了说明组装结果变了,去 diff 内容;hash 没变说明问题在模型侧或者输入侧。这一步能砍掉一半的排查时间,我强烈建议加上。
# agents-templates/my_agent/agent.yaml 片段 id: my_agent version: "1.4.2" extends: _base sections: - id: identity file: identity.md priority: 100 merge: replace - id: persona file: persona.md priority: 80 merge: replace - id: constraints file: constraints.md priority: 90 merge: append - id: skills dir: skills/ priority: 40 merge: dedupe_by_name - id: protocol file: protocol.md priority: 95 merge: replace注意merge: append用在constraints上——子级加约束时应该叠加到父级之上,而不是替换掉父级的约束。这个字段选错,安全边界就可能被子级悄悄抹掉。
4. 实测中反复踩的几类坑与排查链路
4.1 空字符串把整段人格吞掉
现象:在agent.yaml里给persona加了一个tone: "",本意是"这一项不特殊指定,用默认的",结果整个 persona 段消失了。
排查链路是这样的:先打印最终提示词,确认 persona 段真的不在;再打印加载后的模板对象,发现persona这个键还在,但值是个空字符串;再看组装器里拼接的那行代码,写的是sections.append(tpl.persona or "")——空字符串在 Python 里是假值,or直接短路,虽然结果还是空串,但更致命的在下一层:上层有个过滤逻辑if not section_text: continue,空字符串被当成"这段没有内容"给跳过了。
根因是**"空内容"和"未配置"在下游被合并成同一种状态**。修复方式有两种,我选了更彻底的那种:在模板解析阶段就把三种状态区分开——None表示未配置、""表示显式空、有内容表示有效值。组装时只跳过None,""照常参与拼接(拼出个空段,不影响其他段)。同时把那句or ""改成显式的if is not None。这个改动看着很啰嗦,但它让"清空"这个操作变得可靠了。
同类问题在 YAML 里还有个变体:tone:后面什么都不写,解析出来是None而不是"",如果配置里想写空值,得写tone: ""或者tone: null。这两种在 YAML 里语义不同,团队里必须统一,否则同一个配置两个人写出两种语义。
4.2 模板继承引发的字段漂移
现象:某个 agent 的人格在用了一周后开始"跑偏",说话越来越啰嗦。查了半天,发现父级模板的persona.md被人加了四段新内容,子级没有覆盖这个文件,于是新内容顺着继承链流了下来。
这类问题的本质是继承是隐式的、变更也是隐式的。父级改一处,所有子级同时受影响,但没有任何一个子级的 owner 知道自己被改了。我在一个三人协作的项目里遇到过更糟的情况:父级的persona.md里写了一句"回答尽量详细",本意是给文档类 agent 用的,结果所有继承它的 agent 都变得啰嗦,包括那个本该极简的客服 agent。
我的应对办法有三条,成本递增:
- 子级对关键字段一律显式覆盖,哪怕内容和父级一样。覆盖了就是"我认可这个值",父级再变也不会漂。代价是重复,但重复在提示词里是好事。
- 父级模板的每次变更都要在变更说明里列出受影响的子级列表。这个可以脚本化:扫所有
agent.yaml的extends,生成反向索引。 - 加一个"组装结果快照"测试,把每个 agent 的最终提示词存成文件,CI 里比对。任何继承引发的漂移都会在快照 diff 里暴露出来。
第三条是最有效的。快照测试听着笨,但它对"隐式变更"的捕捉能力是其他手段比不了的。快照文件我一般放在tests/prompts/<agent_id>.snap,人眼扫一眼 diff 就知道哪段变了。
4.3 缓存键漏了版本号导致热更新失灵
现象:改完模板,重载,日志里能看到文件重新读了,但对话行为没变。第一反应是模型侧缓存,等了几分钟还是没变。
排查过程:先在组装入口打日志,打印最终提示词的 hash。发现 hash 和改之前一模一样。那说明组装结果没变。再往上查,发现build_static直接命中了缓存,而缓存 key 是agent_id,没带版本号也没带内容 hash。文件确实重新读了,但读完之后发现 key 命中,直接把旧结果返回了。
这个坑的修复很简单,key 里加上version和模板内容的content_hash。但它引出一个更值得重视的问题:内存缓存的失效策略,必须和"模板文件变化"这个事件对齐。我的做法是监听文件变更事件,变更时把该 agent 的所有缓存 key 清掉,而不是等 TTL 过期。TTL 过期在开发阶段是灾难——你改十次,有九次看到的是旧结果,会严重怀疑自己。
还有一个变种坑是动态层污染缓存。有次我把user.display_name放进了静态模板,结果是 A 用户的称呼出现在了 B 用户的对话里。这种 bug 在测试环境几乎发现不了(就一个人测),上线才会炸。凡是和用户相关的变量,一律不允许出现在静态层,我现在会在渲染器里加一个断言:静态阶段遇到user.前缀的占位符直接抛错。宁可启动失败,也不要串号。
4.4 段落粘连导致能力说明被"演"出来
现象:agent 反复声称自己"具备联网检索能力",实际上工具列表里根本没有检索工具。
查最终提示词,发现能力段末尾和技能段开头之间只有一个换行,两段文字粘在一起,读起来像是"你具备 X 能力,可以使用检索工具,调用前需确认……"——而那句"可以使用检索工具"本来是父级模板里的一个反例示意,写的是"不要声称可以使用检索工具"。因为少了个分隔符和一个小标题,反例的否定语境丢了,模型把它读成了正面能力声明。
这类问题最隐蔽,因为模板文件本身没写错,错在拼接。修复方案就是我前面说的带标题分隔:每段前面加## 能力边界、## 可用工具这样的小标题,让每段的语境闭合。改完之后,反例的否定语义能稳定生效。
顺便提一个相关的经验:否定句在提示词里天然脆弱。你写"不要做 A",模型有时候会理解成"A 是一个相关概念"。所以关键禁令我会写成"正面表述 + 明确边界",比如不说"不要编造链接",而说"只引用输入中出现的链接,输入里没有链接时直接说明无法提供"。正面表述比否定表述稳得多,代价是要多写几个字。
5. 把组装过程变成可观测的:调试手段与自检清单
提示词工程最大的问题是不可观测。代码出错会抛异常,提示词出错只是"感觉不太对"。所以我会在组装器上强制挂三样东西。
第一样是最终提示词的落盘。开发环境里每次组装都写一份到debug/prompts/<agent_id>-<timestamp>.txt,不带任何裁剪。这东西看着占地方,但它是唯一的事实来源。我在排查 90% 的"人格不生效"问题时,第一步都是打开它看一眼。很多问题看一眼就明白了——段落顺序错了、某段是空的、变量没替换成功留了个{{xxx}}在里面。
第二样是段落指纹表。每次组装输出一张小表,列出每个段的 id、字符数、hash 前八位。这张表的价值在于快速定位"哪一段变了"。
| 段 id | 字符数 | hash | 状态 |
|---|---|---|---|
| identity | 86 | 3f9a21c4 | 命中缓存 |
| persona | 412 | 8b2e77d1 | 重新渲染 |
| constraints | 233 | 1c4f0a92 | 命中缓存 |
| skills | 1087 | 5d6b3e80 | 合并 3 项 |
| protocol | 298 | 9a1e44f2 | 命中缓存 |
看到persona是"重新渲染"而其他都是"命中缓存",就知道这次改动只影响了人格段,符合预期。如果某个你没动过的段也重新渲染了,说明回落链命中的层变了,得去查继承。
第三样是三个断言。组装完成后立刻检查:一是身份段非空且长度在合理区间(太短说明回落到了_base,太长说明有人往里塞了不该塞的东西);二是所有占位符都已替换完毕,文本里不允许残留{{;三是总长度不超过预算,超了就走裁剪流程并打警告。这三条断言写起来不到二十行,但它们把"静默错误"变成了"显式报错"。
最后分享一个我自己的习惯:给每个 agent 写一份NOTES.md,不写别的,只写"这个 agent 继承了谁、为什么继承、哪些字段是显式覆盖的、最近一次行为异常是什么原因"。这份笔记的价值在两个月后体现——那时候你已经忘了当初为什么给constraints选了append还是replace,而这份笔记会告诉你,当时是因为父级加了一条安全约束,子级不能抹掉它。提示词工程的复杂度不在于写,而在于半年后还能不能改得动。