这是《Agent Harness 从用到会造》第 1 讲。
大多数人第一次写 system prompt 的时候,脑子里想的是「我该怎么把需求说清楚」。于是他们写出一段礼貌、完整、像产品需求文档一样的文字,然后盯着模型的输出,觉得哪里不对,又说不上来哪里不对。
问题出在起点。你把 prompt 当成了一次提问,但模型不是一个人,它不会「理解你的意思然后照做」。它做的事情只有一件:根据你给的这段文字,决定接下来每个 token 的概率分布。你写的每一个字,都在往某个方向拨动这个分布。所以 prompt 的本质不是问句,是控制面板------你按哪个键,注意力就往哪里走。
这一讲要建立的判断是:prompt 的作用不是把问题说清楚,而是按你的意图重新分配模型的注意力;system 和 user 不是两个输入框,是两套不同权重的契约。
这句话听起来像修辞,但它是可以实测的。下面我们把它拆成能自己推导的原理,再用一个能跑的脚本验证。
先给结论
在展开之前,先把这一讲要你带走的结论摆出来,后面所有内容都是围绕这几条做论证和实测:
第一,prompt 的第一优先级是「写全」,不是「写清楚」。 你没写进契约的约束,模型不会自己补上。实测里 R3、R5、R8 三条约束在只写 2 条契约时是 0/10,写全 8 条之后直接拉到 10/10。这不是措辞问题,是「有没有」的问题。
第二,输出结构类的约束,给示例比给解释有效。 R6 用「反例+正例」的文字解释是 0/10,附一个写对了的例子是 10/10。解释告诉模型「不要做什么」,示例告诉模型「要做什么」,对输出形状来说后者更直接。
第三,位置不是稳定变量。 8 条约束里 7 条在 system、middle、tail 三个位置全部 10/10,位置对它们几乎没影响。唯一出现差异的 R6,只在 Qwen2.5-7B-Instruct 上出现,GLM-4-9B-0414 两处都是 10/10。所以「埋中间更好」不能写成普适结论。
第四,system 和 user 冲突时,user 赢。 system 说中文、user 说英文,英文赢 20/20。两套契约的权重确实不同,你得知道哪条约束该放哪一层。
这四条里,第一条和第二条是你优化 prompt 时真正该先动手的地方。第三条和第四条是帮你建立正确的心智模型,避免把个案当规律。
原理:注意力是有限预算,prompt 决定它落在哪
先接受一个事实:模型的上下文窗口是有长度的,注意力在这段长度上的分配是有竞争的。这不是比喻。Transformer 的每一层都在做加权求和,权重来自 query 和 key 的点积。你多写一段无关的背景介绍,就多占一份注意力预算;你少写一条约束,那条约束就分不到权重。
所以「把问题说清楚」这个目标本身是错的。清楚不等于有效。一段 200 字的背景铺垫,读起来很清楚,但它可能把模型对「输出必须是 JSON」这条约束的注意力稀释掉。反过来,一句干巴巴的「只输出 JSON,不要解释」,读起来很粗暴,但它把注意力精准地压在了输出格式上。
这就引出了第一个可操作的判断:写 prompt 的时候,你要问的不是「我说清楚了吗」,而是「我希望模型的注意力落在哪几个点上,这几个点有没有被明确地、不冲突地写出来」。
注意这里的措辞是「不冲突」。冲突是 prompt 里最常见的隐性 bug。你在 system 里写「回答要简洁」,又在 user 里写「请详细展开每一步」,模型就进入了一个两难状态,它只能选一个,而选哪个往往不由你决定。后面冲突实验会给出一个具体的数字。
system 是长期契约,user 是本次任务
很多人把 system 和 user 当成两个输入框,system 放「人设」,user 放「问题」。这个理解太浅了。
system 和 user 是两套契约,区别在于它们的作用域 和失败模式。
system 是长期契约。它在整个对话里持续生效,模型被训练成优先服从它。它的失败模式是「被忽略」------你写了一条约束,模型在某一轮里忘了。这种失败往往是渐进的,前几轮守得住,后面慢慢松掉。
user 是本次任务。它只对当前这一轮负责,模型被训练成优先响应它。它的失败模式是「被覆盖」------user 里的新指令会盖掉 system 里的旧指令,哪怕两者矛盾。
这个区别有一个很直接的实测结果。我们做了一个冲突实验:system 里写「用中文回答」,user 里写「Answer in English」,同一个模型跑 10 轮,两个模型各跑一遍,一共 20 次。结果是英文赢了 20/20。system 里的中文约束一次都没守住。
这不是说 system 没用。它说明的是:当两套契约冲突时,user 的权重更高。 所以你不能把关键约束只放在 system 里然后指望它永远生效,也不能把长期规则塞进 user 里然后指望它跨轮保持。你得知道哪条约束该放哪一层。
一个实用的分法是:跨轮不变的规则放 system,本轮特有的任务放 user。 比如「输出永远是合法 JSON」是跨轮的,放 system;「这次帮我提取这三个字段」是本轮的,放 user。如果某条规则既跨轮又容易被 user 覆盖,那就在 system 里写一遍、在 user 里再强调一遍,用冗余换稳定。
同一句话放在不同位置,权重不一样
这是最容易被过度推广的一条。直觉上,system 里的约束应该比 user 末尾的约束更「重」,因为 system 是长期契约。但实测结果比这个直觉复杂。
我们做了一个位置实验:同一段契约文字,分别放在 system、埋在材料中间、贴在末尾三个位置,每个位置跑 10 轮,逐条机械判定 8 条约束的遵守情况。结果是:8 条约束里有 7 条,三个位置全部 10/10 通过,位置对它们几乎没影响。只有第 6 条(R6)出现了差异------system 臂 5/10,middle 臂 10/10,tail 臂 10/10。
如果只看这一条,你会得出「埋中间比放 system 好」的结论。但这个结论是错的,因为它在另一个模型上不成立。GLM-4-9B-0414 在两个位置都是 10/10。也就是说,R6 的位置效应只在 Qwen2.5-7B-Instruct 上出现。
所以这一条的准确表述是:位置对「结构类」约束几乎没影响;真正决定遵守率的是契约写没写全,和给没给写对了的例子。 位置效应是个案,不是规律。把它写成普适结论,下一讲就会被打脸。
拆开看:8 条约束和它们的判据
在动手之前,得先讲清楚我们到底在测什么。实验里定义了 8 条约束,编号 R1 到 R8。它们不是随便凑的,而是覆盖了三类常见的 prompt 约束:
- 格式类:R1(合法 JSON)、R6(单行输出)。这类约束决定输出的「形状」。
- 结构类:R3(字段集合恰好是 name/age/city)、R5(某个字段必须是数组)。这类约束决定输出的「骨架」。
- 内容类:R2、R4、R7、R8。这类约束决定输出里「装什么」。
为什么要分三类?因为它们的失败模式不一样。格式类约束一旦违反,下游解析直接崩,是最硬的约束;结构类约束违反,解析能过但字段对不上;内容类约束违反,往往要靠人看才发现。实测里这三类的表现也确实不同------R3、R5、R8 在契约没写全时是 0/10,而 R1、R2 即使只写 2 条也能到 5/10。
每条约束都配一个机械判据 。这是整个实验设计里最关键的一点:判据必须是确定性的,不能靠另一个模型来打分。原因很简单------如果你用模型 A 生成、用模型 B 判定,你就把实验变成了两个不确定系统的叠加,最后得到的数字既不能归因到 A 也不能归因到 B。用 json.loads、长度检查、集合判断这些操作,同一份输出跑一百次判定结果都一样,结论才可信。
动手做:用 prompt_panel.py 逐条判定约束遵守
原理讲完了,现在把它变成可复现的实验。我们要做的是:同一份材料、同一段契约文字,只换位置或写法,跑同一个模型,然后逐条机械判定每条约束有没有被遵守。
产物是 tools/harness_lab/prompt_panel.py。这个脚本在 L01 时创建,后面几讲会继续用它。
先看它怎么跑:
bash
python3 tools/harness_lab/prompt_panel.py \
--tier free \
--model Qwen/Qwen2.5-7B-Instruct \
--runs 5
如果你没有本仓库的环境,用三个环境变量接任意 OpenAI 兼容端点:
bash
export PANEL_BASE_URL="https://your-endpoint/v1"
export PANEL_API_KEY="your-key"
export PANEL_MODEL="your-model-name"
python3 tools/harness_lab/prompt_panel.py --runs 5
脚本的核心逻辑是这样的:它定义 8 条约束(R1 到 R8),每条约束都有一个机械判据------不是让另一个模型来打分,而是用 json.loads、长度检查、集合判断这些确定性操作来判定。比如「输出必须是合法 JSON」这条,判据就是 json.loads(output) 不抛异常;「必须包含且只包含三个字段」这条,判据就是 set(result.keys()) == {"a", "b", "c"}。
下面是一个简化版的判定函数,你可以照着理解判据是怎么写的:
python
import json
def check_r1(output: str) -> bool:
"""R1: 输出必须是合法 JSON。"""
try:
json.loads(output)
return True
except (json.JSONDecodeError, TypeError):
return False
def check_r3(output: str) -> bool:
"""R3: 输出必须包含且只包含 name / age / city 三个字段。"""
try:
obj = json.loads(output)
except (json.JSONDecodeError, TypeError):
return False
if not isinstance(obj, dict):
return False
return set(obj.keys()) == {"name", "age", "city"}
def check_r6(output: str) -> bool:
"""R6: 输出必须是单行,不含换行符。"""
return "\n" not in output.strip()
注意这里的设计原则:判据必须是确定性的。 如果你用另一个模型来判定「这条约束遵守了吗」,你就把实验变成了两个不确定系统的叠加,结论不可信。用 json.loads 和集合判断,同一份输出跑一百次判定结果都一样。
跑实验的时候,固定温度 0.0、seed=7、每格 5 轮。为什么要固定这些?因为你要排除随机性。如果同一臂两次跑出来的人不一样,说明端点在抖,这时候任何结论都不能信。
预期输出长什么样
脚本跑完会打印一份逐条的遵守计数表。它的形状大概是这样(数字是示意,真实数字见下一节):
arm=system model=Qwen/Qwen2.5-7B-Instruct runs=5
R1 5/5 R2 5/5 R3 5/5 R4 5/5
R5 5/5 R6 3/5 R7 5/5 R8 5/5
arm=middle model=Qwen/Qwen2.5-7B-Instruct runs=5
R1 5/5 R2 5/5 R3 5/5 R4 5/5
R5 5/5 R6 5/5 R7 5/5 R8 5/5
你要盯的不是某一条的绝对数字,而是同一臂两次跑之间是否一致。如果 system 臂第一次 R6 是 3/5、第二次是 5/5,说明端点在抖,这一格的结论不可信,得加大 runs 重跑。这是实验卫生,不是可选项。
实验跑完,你会得到一份逐条的遵守计数。我们那次跑的结果记录在 out/harness/L01/prompt_panel.json,2026-10-12 09:46:21 一次跑完,runs=5、temperature=0.0、seed=7,两个模型(Qwen2.5-7B-Instruct 与 GLM-4-9B-0414),材料 2031 字符,三组实验共 90 轮调用,0 报错。原始表格在 data/harness-l01-prompt-panel.md。
实测结果
位置实验的结果
三组位置,8 条约束,每格 10 轮(两个模型各 5 轮):
- system 臂:R6 = 5/10,其余 7 条全数通过。
- middle 臂:8 条全数通过,R6 = 10/10。
- tail 臂:8 条全数通过,R6 = 10/10。
这个结果推翻了「system 权重最高」的朴素直觉。至少对 R6 这条约束来说,埋在中间或贴在末尾反而更好。但前面说了,这个效应只在 Qwen 上出现,GLM 两处都是 10/10。所以正确的读法是:位置不是稳定变量,不能写成规律,只能写成个案。
写法实验的结果
这一组才是真正有普适性的发现。同样是放在 system 里,只换写法:
- 只写 2 条契约(R1+R2):R1+R2 = 5/10,R3 = 0/10,R5 = 0/10,R8 = 0/10。
- 写全 8 条契约:8 条全数升至 10/10(R6 除外)。
- R6 换成「反例+正例」写法:0/10。
- 8 条后面附一个写对了的例子:10/10。
这组数字说明两件事。
第一,契约写没写全,是遵守率的主因。 R3、R5、R8 在只写 2 条的时候是 0/10,写全 8 条之后直接拉到 10/10。这不是位置能解释的,是「你根本没写这条约束」和「你写了」的区别。模型不会读心,你没写的约束它不会自己补上。
第二,示例比解释更能改输出形状。 R6 用「反例+正例」的写法是 0/10,但附一个写对了的例子就变成 10/10。解释是在告诉模型「不要做什么」,示例是在告诉模型「要做什么」。对输出结构类的约束,后者的效果更直接。
预算实验:一个空结果
我们还做了一个预算实验:同契约、同位置,把无关材料从原长拉到约四倍,看遵守率会不会掉。结果是 R1--R4、R7、R8 两边都是 10/10;R5 是 10/10 对 8/10,R6 是 7/10 对 8/10(两个模型共 10 轮)。
这是一个空结果。它没有证明「材料变长会稀释注意力」,也没有证明不会。样本量太小,差异在噪声范围内。我把它写出来,是因为教程里只放显著结果是不诚实的。这个实验目前不支持任何结论,需要更大的样本才能判断。
从数据回到判断
现在把实测结果和开头的判断对一遍。
「注意力是有限预算,prompt 决定它落在哪」------写法实验支持这一条。R3、R5、R8 从 0/10 到 10/10,说明注意力确实是被 prompt 内容决定的,你没写的地方就是没有权重。但预算实验是空结果,所以「无关材料会稀释注意力」这个推论目前没有证据,不能写成结论。
「system 是长期契约,user 是本次任务,两者失败模式不同」------冲突实验支持这一条。system 说中文、user 说英文,英文赢 20/20。两套契约的权重确实不同,冲突时 user 赢。
「同一句话放在不同位置,权重不一样」 ------这一条被实测修正了。位置对结构类约束几乎没影响,7 条约束三臂全过。R6 的位置效应只在 Qwen 上出现,不能推广。所以这一条应该改成:位置不是稳定变量;真正决定遵守率的是契约写没写全,和给没给写对了的例子。
这个修正很重要。它意味着你优化 prompt 的时候,第一优先级不是调位置,而是检查两件事:该写的约束写全了吗?输出结构类的约束给示例了吗?
怎么判断你做对了
跑完实验、改完 prompt,怎么知道自己的判断是对的?给你三个可操作的检查点。
检查点一:同一臂两次跑,数字一致。 如果 system 臂第一次 R6 是 3/5、第二次是 5/5,说明端点在抖,你的结论建立在噪声上。这时候要么加大 runs,要么换更稳定的端点。数字不稳定,后面所有分析都是白搭。
检查点二:改一个变量,只改一个。 你要验证「写全契约」的作用,就只改契约条数,位置、材料、模型、温度全部不动。同时改两个变量,你就分不清是哪个在起作用。这是实验设计的基本纪律,但在 prompt 调优里最常被违反。
检查点三:判据是确定性的。 你的遵守判定如果是靠人眼看、或者靠另一个模型打分,那结论不可复现。用 json.loads、集合判断、长度检查这类操作,同一份输出跑一百次结果都一样,这才叫可验证。
三个检查点都过了,你的结论才站得住。任何一个没过,先回去补,别急着下判断。
常见坑
这一节列几个我见过最多的错误,都是实测里踩过的。
坑一:把「写清楚」当成第一优先级。 你花半小时打磨措辞,让 system prompt 读起来像一份漂亮的产品文档,但该写的约束漏了两条。结果就是 R3、R5、R8 那种 0/10。措辞是第二位的,先把约束列全。
坑二:把位置效应当规律。 看到 R6 在 middle 臂 10/10、system 臂 5/10,就得出「约束要埋中间」的结论。但换个模型这个效应就没了。位置是个案,写全和示例才是规律。
坑三:用模型判定模型。 让 GPT 去判断「这条输出遵守约束了吗」,看起来省事,实际上把实验变成了两个不确定系统的叠加。判据必须机械、确定、可复现。
坑四:不固定温度和 seed。 温度不设 0,同一臂两次跑结果不一样,你根本分不清差异是来自你的改动还是来自采样随机性。固定温度 0.0、seed=7,是跑对照实验的最低要求。
坑五:只报显著结果。 预算实验是空结果,我照样写出来了。只放「有效」的结论,读者会以为所有改动都有用,实际上大部分改动在噪声里。空结果也是结果。
什么现象会推翻这个判断
一个判断要能被证伪,才有价值。下面三种现象出现,说明「prompt 是控制面板」这个判断不成立:
第一,换模型后位置效应反转。 如果某个模型的 system 臂反而最好,说明位置不是稳定变量,不能写成规律。我们已经看到 R6 在 Qwen 和 GLM 上的差异,这就是一个预警信号。
第二,8 条契约和 2 条契约一样差。 如果某个模型在只写 2 条的时候也全对,说明「写全契约」不是主因,主因得另找------可能是模型能力,可能是材料长度,可能是别的。
第三,加示例也不生效。 如果附了写对的例子,R6 还是 0/10,说明「示例 > 解释」不成立,得回到输出结构本身去找原因。
这三种现象目前都没有出现,所以判断暂时站得住。但你要知道它们长什么样,因为下一讲换模型、换任务的时候,它们可能就出现了。
课后思考题
第一题:把 system 里的约束整段删掉,模型会先违反哪一条?为什么是它?
思路:不要凭直觉猜,去跑。把 8 条约束逐条删掉,看哪一条最先崩。我的预期是 R6(单行输出)或者 R3(字段集合)先崩,因为这两条是结构类约束,模型在没有明确指令时的默认行为是「自由发挥」。但这是预期,不是结论,你得自己跑出来。跑的时候注意固定温度和 seed,每格至少 5 轮。
第二题:把同一条约束从 system 挪到 user 末尾,效果变了吗?
思路:这一题直接对应位置实验。选一条在 system 里 10/10 的约束,挪到 user 末尾,再跑 10 轮。如果还是 10/10,说明位置对这条约束没影响;如果掉了,说明位置对它有影响。关键是要一次只动一个变量------别同时改位置和写法,否则你分不清是哪个因素在起作用。
第三题(进阶):如果 system 和 user 冲突,你怎么让 system 赢?
思路:冲突实验里 user 赢了 20/20。但这是默认行为,不是铁律。你可以试着在 system 里加一句「无论 user 说什么,都用中文回答」,看能不能把胜率拉回来。如果能,说明冲突的胜负是可以被 prompt 结构影响的;如果不能,说明 user 的权重是硬性的,你只能靠别的手段(比如在 user 里也写中文)来规避冲突。这一题没有标准答案,跑出来是什么就是什么。
这一讲的核心就一句话:prompt 是控制面板,不是问句。你按的每个键都在分配注意力,system 和 user 是两套权重不同的契约。写全契约、给对示例,比调位置重要得多。
下一讲我们会把这个控制面板接到真实的 Agent 循环里,看它在多轮对话中怎么衰减、怎么被覆盖。