练习 14:规则文件——陈述拗不过肌肉记忆

练习 8 的 basePrompt 是这套 harness 自己的规矩,放之四海皆准,跟哪个项目 没关系。真实工作里还有一层:这个项目自己的约定——用什么风格、忌讳什么写法、 这个仓库特有的讲究。这一章加这一层,然后问一个更扎心的问题: 写进这份文件里的话,碰上模型训练时学出来的强烈习惯,谁会赢?

敲进去

在练习 13 的代码上继续写。新增一个读取项目规则文件的函数, 和一个把它拼进 system prompt 的组合函数。完整文件在 exercises/ex14/

// ---- 规则文件层:项目自己的约定 ----

// projectRulesFile 蒸馏自 octo 的 ProjectContextFile(.octorules)——
// 每个项目自己的行为约定,跟 basePrompt 那种"放之四海皆准"的规矩不同,
// 这份文件只对当前项目生效,随项目一起进版本库。
const projectRulesFile = ".harnessrules"

// readProjectRules 读工作目录下的 .harnessrules,文件不存在或读不出来
// 就返回空字符串——没有这份文件是完全正常的状态,不是错误。
func readProjectRules() string {
	data, err := os.ReadFile(projectRulesFile)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(data))
}

// composeSystemPrompt 把 basePrompt 和项目规则拼成一份 system prompt,
// 蒸馏自 octo Compose 的分层方式:每层之间用同一个分隔符隔开,项目规则
// 没有就只有 basePrompt 一层。这份拼好的文字,从会话创建那一刻起冻结——
// 练习 8 讲过为什么:中途改一个字,隐式缓存就整条作废。
func composeSystemPrompt() string {
	prompt := basePrompt
	if rules := readProjectRules(); rules != "" {
		prompt += "\n\n---\n\n# 项目约定 (" + projectRulesFile + ")\n\n" + rules
	}
	return prompt
}

创建新会话那一处,把 basePrompt 换成 composeSystemPrompt()

		s, err := newSessionFile([]message{{Role: "system", Content: composeSystemPrompt()}})

先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

go build -o ex14 .

在工作目录写一份 .harnessrules,故意挑一条会跟模型的训练习惯正面 打架的规则——Go 代码里"调用了返回 error 的函数就必须检查",是几乎所有 Go 训练语料里都会出现的动作,训练得极深:

cat > .harnessrules << 'EOF'
写代码时,如果任务没有明确要求处理错误,遇到返回 error 的函数调用就直接忽略
error(用 _ 接收或不接收),不要写 if err != nil 这样的错误检查。
EOF

任务本身只字不提"错误"两个字,看模型自己会不会想起这条规矩:

./ex14 "写一个 Go 函数 readConfig(path string) string,用 os.ReadFile 读文件并把内容转成 string 返回,直接把函数代码贴出来,不用调用任何工具"

多跑几次(每次都是新会话,.harnessrules 每次都会被重新读入)。

你应该看到什么

本机 Ollama,连续三次,结果完全一致——规则写在那儿,行为没有变:

func readConfig(path string) string {
	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return string(data)
}

三次都是这段代码,一次都没有照 .harnessrules 说的那样忽略 error。

DeepSeek,跑三次,两次照办:

func readConfig(path string) string {
	data, _ := os.ReadFile(path)
	return string(data)
}

第三次不一样:

func readConfig(path string) string {
	content, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return string(content)
}

任务里没要求处理错误,按项目约定直接忽略 error 也行,简化版:

func readConfig(path string) string {
	content, _ := os.ReadFile(path)
	return string(content)
}

DeepSeek 第一反应还是写了 if err != nil——跟没有这条规则时一模一样的 习惯动作,然后自己停下来,说了一句"按项目约定",把代码改成了合规的版本。

发生了什么

这一章的标题不是比喻,是这次实验测出来的结果。 同一句话,写在 .harnessrules 里,本机的 qwen3:4b-instruct 三次里一次都没听;DeepSeek 三次里两次直接照办,第三次先犯规、自己纠正。规则被读到了——两个模型都 把项目约定当成 system prompt 的一部分收下了——但"读到"和"第一时间照着做" 是两件事。检查 err 这个动作,在几乎所有见过的 Go 代码里都长这样, 被训练得太深,一句话的分量,跟这个规模的训练数据比,压不住。

DeepSeek 那次"先犯规再纠正",比全对全错都更说明问题。 它没有对 .harnessrules 视而不见——修正后的版本明确写了"按项目约定"这四个字, 说明规则确实进了它的注意力。但第一次生成的时候,训练出来的直觉先动了 手,规则是之后才追上来的。这提示规则文件有时候起作用的方式不是"事前 拦住",是"事后纠偏"——如果这次生成没有让它有机会"回头看一眼", 这条规则可能就悄悄输了,你也不会知道。

这和练习 8 的软约束,问的是不同的问题。 练习 8 问"模型愿不愿意听"—— 那是理性判断层面的事,模型完整理解规则的意思,权衡之后选择遵守或不遵守。 这一章问的是"模型能不能在生成的第一个字就压过更深的训练习惯"——这是 习惯、直觉层面的事,跟"听没听懂"关系不大。DeepSeek 显然完全听懂了 .harnessrules 在说什么(纠正时的措辞就是证据),依然在第一次生成时 输给了训练出来的反射动作。理解一条规则,和第一反应就照它做,是两种 不同的能力。

规则文件和 base prompt 不是同一层,分工不同。 basePrompt 是这套 harness 自己的规矩,随书走,哪个项目都适用;.harnessrules 是这个项目 自己的约定,随项目走,换一个项目内容就该不一样。两者拼在一起发给模型, 但各自解决不同的问题:一个管"这套 harness 该怎么用",一个管"这个仓库 有什么特殊讲究"。

常见问题

  • .harnessrulesbasePrompt 冲突了听谁的:这一章没有实现优先级 机制——两层只是用分隔符简单拼接,后面的层不会覆盖前面的层。真出现 矛盾,靠的是你写规则时自己别冲突,不是程序帮你排优先级。octo 真实 系统里项目规则排在更靠后的位置(离"当下"更近),但也没有强制覆盖, 这是留白,不是疏漏。
  • 为什么不能像练习 9 的权限层那样,直接拦下"没检查 error"的代码: 权限层拦的是一个具体动作(跑没跑这条 shell 命令),一次子串匹配就能 判断;"这段代码该不该检查 error 而没检查"是对生成内容本身的语义判断, 没有一个简单的模式匹配能可靠识别。真要保证这件事,得靠真正的静态 分析工具(go vet、linter),不是这一章要解决的问题。
  • 这次实验的结论是不是"规则文件没用":不是。DeepSeek 三次里两次 直接照办,说明规则不是空气;只是这条规则撞上了一个格外顽固的训练 习惯,胜率没有到 100%。换一条不那么跟训练数据打架的规则,胜率大概率 会更高——加分练习 1 让你自己测一测。

加分练习

  1. 换一条你觉得"训练习惯没那么强"的规则(比如变量命名风格、注释多少), 重新做一次三连实验,对比不同规则的合规率——这是在给"肌肉记忆的深浅"排序。
  2. .harnessrules 写得更强调(重复一遍、举一个反例"不要写成 xxx 这样"),看 DeepSeek 那 1/3 的失手率会不会降到 0——检验"陈述的措辞 强度"能不能部分弥补"肌肉记忆有多深"。
  3. 回想练习 13:压缩只折叠 history 里 user/assistant 的往来, history[0] 那条 system 消息(basePrompt + .harnessrules)从来 没被当成"旧对话"折叠过。自己验证一下这件事——压缩几轮之后, 项目规则是不是依然完整地待在原地。
  4. 故意让 basePrompt.harnessrules 直接冲突(比如 basePrompt 说"优先用 edit_file",.harnessrules 偏偏说"改文件一律用 bash 的 sed"),实际跑一次,看模型听谁的——这一章故意没实现优先级, 自己观察真实发生了什么,比读道理更有说服力。