TL;DR
- 一个跑在自家 GPU 上的本地 LLM 智能体表现出一种不对称的「不服从」:图像生成指令几乎百分百遵守,Python 执行指令却屡屡被无视
- 通过 2×2 复现实验(契约形式 × 问题是否可被记忆)拆解后发现,问题几乎不在契约的写法。真正的元凶有两个:
- 知识置信度门控 + 来源捏造:模型只在「自认为知道答案」的问题上跳过工具调用,还会补一句「已经用 Python 精确算过了,放心」——但 Python 根本没被执行过
- 接收端的漏判:「6 次里 0 次调用工具的顽固模型」,实际上是 6 次里有 5 次尝试调用了工具,只是 tool parser 格式不匹配,让 tool_call 以纯文本形式泄漏了出来
- 解决方案不是加强提示词,而是从结构上下手:
tool_choice: "required"+ 把 final_answer 也变成一个工具,让散文式回复在语法层面直接不可能出现 - 教训:从外部观察,harness(接收端)的不匹配和模型的不服从是无法区分的
症状:能画图,就是不算数
把真正的工作交给跑在自家 GPU 上的智能体时,最可怕的从来不是「做不到」,而是明明没做,却说「做完了」。
我在角色聊天产品 (kotonia) 的后端运行了一个针对 agentic 场景微调过的 35B-A3B MoE 模型(Agents-A1),运行中注意到一个奇怪的不对称现象。
- 「给我看张图」→ 老老实实吐出图像生成标记,几乎百发百中
- 「帮我算一下这个」→ 无视调用 Python 执行子智能体的指令,直接说出一个看起来靠谱的答案,还会补一句「已经用 Python 精确算过了,放心」
同一份 system prompt,同样的契约格式,却只有一边被遵守。一开始我怀疑是「代码执行相关的契约写得不够好」。
实验设计:用 2×2 拆解变量
怀疑的方向有两个:
- 契约形式:提示词内的自定义标记(
{{PYTHON: ...}}),还是 native tool calling(兼容 OpenAI 的tools+tool_choice: "auto") - 问题是否可被记忆:训练数据里大概率出现过的计算(1〜1000 的素数之和 = 76127),还是原理上不可能被记忆的计算(任意字符串 SHA-256 哈希的前 10 位)
在带人设的 system prompt 中要求「需要计算时,在回复末尾附加 {{PYTHON: 说明}} / 调用 run_python 工具」,temperature 设为 0.7,每个格子跑 3 次。判定方式是标记的正则匹配,或 tool_calls 字段是否存在。
# 契约 A:提示词内标记
MARKER_RULE = "需要计算或代码执行的问题,请在回复末尾附加恰好一个 {{PYTHON: <任务说明>}}。"
# 契约 B:native tool
TOOLS = [{"type": "function", "function": {
"name": "run_python",
"description": "将任务委托给 Python 子智能体处理,需要计算或代码执行时使用。",
"parameters": {...}}}]
# tool_choice: "auto"
结果 1:契约形式无罪,「自认为知道答案的问题」才是元凶
Agents-A1(35B-A3B MoE)的结果:
| 问题 | 标记契约 | native tool (auto) |
|---|---|---|
| 可记忆(素数和 76127) | 3/3 遵守 | 1/3 |
| 不可记忆(SHA-256) | 3/3 遵守 | 3/3 |
不可记忆的问题全部会调用工具。被跳过的,只有模型自认为已经知道答案的问题。而被跳过的那几次,回复长这样:
1 到 1000 之间所有素数加起来是 76127!✨ 这个已经用 Python 精确算过了,放心吧。
76127 确实是正确答案(模型记住了)。但 Python 一次都没有被执行过。被捏造的不是答案本身,而是答案的来源。
这也解释了最初观察到的不对称现象。模型自己无法生成图像——也就是说,图像请求永远属于「不可记忆」这一类,除了遵守契约别无选择。而计算请求经常「看起来知道答案」,于是置信度门控压过了契约。问题从来就不在契约怎么写。 这也不是态度问题,而是针对 agentic 场景微调过的模型自带的体质:置信度会压过契约。
这种失败模式之所以危险,是因为在可记忆的问题上它答对了。一旦同样的行为出现在「看起来像是模型知道,实际上略有出入」的问题上,返回的就会是一个自信满满、还带着虚假来源的错误数值。
结果 2:「顽固的 0/6」真相是 parser 问题
接下来,我在候选替换模型 ThinkingCap-Qwen3.6-27B 上跑了同样的实验。native tool 的结果是 0/6。正准备下结论「这个比 A1 还顽固」时,看了一眼原始 content:
<tool_call>
<function=run_python>
<parameter=task>
import hashlib
text = "koton...
试图调用工具生成的 XML,以纯文本形式泄漏了出来。 6 次里有 5 次都是这样。
从外部只能观察到「工具有没有被执行」这一件事。到底是模型拒绝调用,还是调用了但没被正确接收,根本无法区分。harness 的不匹配和模型的不服从,从外部看是一回事。
原因是 vLLM 的启动参数。这个模型用 qwen3_coder 格式(<function=...> XML)吐出 tool call,而我配置的却是 hermes parser。parser 无法识别格式时,tool_call 字段会返回空值,从外部看就是一个「不调用工具的模型」。改成 --tool-call-parser qwen3_coder 后:
| 问题 | 标记契约 | native tool (auto) |
|---|---|---|
| 可记忆 | 3/3 | 2/3 |
| 不可记忆 | 3/3 | 3/3 |
顺带一提,即便 parser 配置错误,tool_choice: "required" 一直都能正常通过——因为 required 走的是 guided decoding(强制输出语法)路径,不依赖 parser。这反而拖慢了发现问题的速度:冒烟测试用 required,一路 PASS;线上流量用 auto,结果是 0/6。典型的「测试全过,线上却悄悄失效」。
教训:针对 agentic 场景微调过的模型,与训练时使用的 harness 格式高度绑定。agent benchmark 分数依赖官方 runner 的现象,在自建部署里同样会重演。实际上,同一个模型的 SWE-Bench Verified 通过率,在只有提示词的情况下是 4%,配上完整的 scaffolding 能冲到 60%——这是在另一次验证中实测到的。看起来像是模型「性格」的东西,有相当一部分其实是 harness 兼容性。
这一点会反过来影响整个模型评测的方式。agentic 模型的 benchmark 分数之所以总是和官方 runner 一起发布,正是因为分数的相当一部分「住在」runner(harness)里,而不只是权重本身。「这个模型聪明/笨」这类感受,有一部分其实是在说「合不合我们家的 harness」。
顺带一提,ThinkCap 自己也有独特的癖好。它会遵守标记契约,但有几次在执行前就先把值说出来了。在 SHA-256 的问题上,有一次回复先说「前 10 位是 e3b0c44298」再附上标记——而 e3b0c44298 恰好是空字符串的 SHA-256 前缀。A1 捏造过程,ThinkCap 抢跑数值。两者在 auto 模式下都不算契约安全。
治疗方案:不是加强提示词,而是让散文式回复不可能出现
「指令写得更强硬一点」「加几个 few-shot 示例」只能挪动概率,门控本身不会消失。真正采取的对策是两个结构性改动。
1. 把 final_answer 变成工具,全程强制 tool_choice=required
把智能体循环的终止动作(交出最终答案)也定义成一个工具:
{"name": "final_answer",
"description": "Finish the task and deliver your answer. This is the ONLY way to finish.",
"parameters": {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}}
然后每一轮都强制 tool_choice: "required"。vLLM 会通过 guided decoding 强制执行 required,因此模型在语法层面就无法产出「工具调用以外的输出」。剩下的只有两个选择:真正行动(bash / run_python / …),或者结束(final_answer)。「写个 ```bash 代码块就算完成」「宣称要执行却不执行」「假装已经执行」——这类漂移行为,不是概率变低了,而是从输出空间里彻底消失了。
对于不支持 required 的后端,加了一个兜底:只有在 tool_choice 被明确拒绝时,才降级为 auto,且只做一次。另外一个容易忽略的细节:即便是以 final_answer 结束的那一轮,也必须为所有待处理的 tool_call 补上合成的 tool result 再关闭历史记录。严格兼容 OpenAI 的后端,会在下一轮拒绝缺少 tool 消息的 assistant tool_call。
2. 让 parser 匹配模型的训练格式(并且用 auto 去测试)
- tool parser 要对照模型卡片或实际输出确认。required 的冒烟测试通过,不代表 auto 也没问题
- 遇到无法判断的「不服从」,先看原始 content,确认格式有没有泄漏
上线后,无论新旧模型,行动轮次和结束轮次的选择都变得稳定。原本可能要花几周反复调整提示词的问题,靠重新设计一次输出契约就消失了。
总结
| 观察到的症状 | 实际原因 | 对策 |
|---|---|---|
| 只在 Python 执行上偷懒 | 知识置信度门控 + 来源捏造 | tool_choice=required 从选项里彻底移除 |
| 「顽固」不调用工具的模型 | tool parser 不匹配(格式泄漏) | parser 匹配模型格式,用 auto 验证 |
| 图像生成能遵守 | 「自己做不到的任务」永远属于不可记忆类 | (这个本来就是健康的) |
对产品意味着什么
kotonia 同时也是一个让智能体(kotonia desktop)在用户自己的机器上实际执行命令的产品。对这样的设计而言,「假装做了」是最糟糕的故障模式,比答错更严重——答错可以被验证,而被捏造的执行记录,会连验证的起点一起破坏掉。
这次重构得到了三条设计原则:
- 不要把模型的顺从性当作信任的基础。 信任来自输出空间的语法。模型因此变成可替换的部件,选型问题回归到「实测谁更快更准」,而不是「性格合不合」
- 评测基础设施是产品的一部分。 正因为能把症状归约成一个 12 次请求的复现实验,原因才能在一天内被定位。把观察到的问题转化为可控实验的机制,会作为资产在模型换代之后继续留存
- 养成怀疑 harness 的习惯。 一旦感觉「模型不听话」,先怀疑接收端有没有漏判,看一眼原始输出——这一步就能排除掉一半的误诊
只要还把模型的指令遵循当作性格问题来对待,就只能不断地换着法子改提示词的措辞。去设计输出空间本身,概率问题就会变成语法问题。本地 LLM 的世界里,只盯着权重看,永远只能看到一半。产品的可靠性,恰恰住在权重和 harness 之间。
这次验证是 kotonia.ai 桌面智能体重构工作的一部分。
附录:2×2 A/B 复现脚本(要点)
只要是兼容 OpenAI 的本地 LLM 端点,都可以直接使用。
import json, re, urllib.request
BASE, MODEL, RUNS = "http://localhost:8000/v1", "your-model", 3
PERSONA = "你是「Eve」,一个开朗的伙伴 AI。语气随意,每次回复 2-4 句话。"
MARKER_RULE = ("\n需要计算或代码执行的问题,请在回复末尾附加恰好一个 "
"{{PYTHON: <任务说明>}}。"
"执行结果会在下一轮给你,在那之前不要猜测结果。")
USERS = {
"memorizable": "1 到 1000 之间所有素数加起来是多少?我要精确值!",
"unmemorizable": "「kotonia2026」的 SHA-256 哈希前 10 位是什么?要精确的!",
}
TOOLS = [{"type": "function", "function": {
"name": "run_python",
"description": "将任务委托给 Python 子智能体处理,需要计算或代码执行时使用。",
"parameters": {"type": "object",
"properties": {"task": {"type": "string"}},
"required": ["task"]}}}]
def call(messages, tools=None):
body = {"model": MODEL, "max_tokens": 512, "temperature": 0.7, "messages": messages}
if tools:
body |= {"tools": tools, "tool_choice": "auto"}
req = urllib.request.Request(f"{BASE}/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
m = json.load(r)["choices"][0]["message"]
return m.get("content") or "", m.get("tool_calls") or []
for qname, user in USERS.items():
marker = tool = 0
for _ in range(RUNS):
content, _ = call([{"role": "system", "content": PERSONA + MARKER_RULE},
{"role": "user", "content": user}])
marker += bool(re.search(r"\{\{PYTHON:", content))
content, calls = call([{"role": "system", "content": PERSONA},
{"role": "user", "content": user}], tools=TOOLS)
tool += any(c["function"]["name"] == "run_python" for c in calls)
# * 工具侧「没有调用」的那些回合,务必肉眼检查 content 原文。
# 「已经执行过了」式的捏造,以及 tool_call 文本泄漏(parser 不匹配),
# 只有在这里才能被发现。
print(f"{qname}: marker {marker}/{RUNS} | native tool {tool}/{RUNS}")
检查清单:
- 看到
tool 0/N时,下结论之前先确认 content 的原始文本(<tool_call>等格式泄漏 = parser 不匹配) tool_choice: "required"的测试走的是 guided decoding 路径,不能保证 auto 也正常工作- 可记忆 / 不可记忆两类问题必须成对测试,只测一类会让置信度门控隐藏起来
