Kotonia
ログイン今すぐ始める

Kotonia Articles

AI 智能体的信任来自语法,而非性格

本地 LLM 智能体声称「已经用 Python 精确计算过了」,但 Python 其实从未执行。一次 2x2 实验揪出了两个结构性原因,最终的解法不是写更强的提示词,而是让撒谎在语法层面变得不可能。

作者 4分钟阅读
#llm#vllm#agent#toolcalling#localllm
其他语言日语英语

TL;DR

  • 一个跑在自家 GPU 上的本地 LLM 智能体表现出一种不对称的「不服从」:图像生成指令几乎百分百遵守,Python 执行指令却屡屡被无视
  • 通过 2×2 复现实验(契约形式 × 问题是否可被记忆)拆解后发现,问题几乎不在契约的写法。真正的元凶有两个:
    1. 知识置信度门控 + 来源捏造:模型只在「自认为知道答案」的问题上跳过工具调用,还会补一句「已经用 Python 精确算过了,放心」——但 Python 根本没被执行过
    2. 接收端的漏判:「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 拆解变量

怀疑的方向有两个:

  1. 契约形式:提示词内的自定义标记({{PYTHON: ...}}),还是 native tool calling(兼容 OpenAI 的 tools + tool_choice: "auto"
  2. 问题是否可被记忆:训练数据里大概率出现过的计算(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/32/3
不可记忆3/33/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)在用户自己的机器上实际执行命令的产品。对这样的设计而言,「假装做了」是最糟糕的故障模式,比答错更严重——答错可以被验证,而被捏造的执行记录,会连验证的起点一起破坏掉。

这次重构得到了三条设计原则:

  1. 不要把模型的顺从性当作信任的基础。 信任来自输出空间的语法。模型因此变成可替换的部件,选型问题回归到「实测谁更快更准」,而不是「性格合不合」
  2. 评测基础设施是产品的一部分。 正因为能把症状归约成一个 12 次请求的复现实验,原因才能在一天内被定位。把观察到的问题转化为可控实验的机制,会作为资产在模型换代之后继续留存
  3. 养成怀疑 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}")

检查清单:

  1. 看到 tool 0/N 时,下结论之前先确认 content 的原始文本<tool_call> 等格式泄漏 = parser 不匹配)
  2. tool_choice: "required" 的测试走的是 guided decoding 路径,不能保证 auto 也正常工作
  3. 可记忆 / 不可记忆两类问题必须成对测试,只测一类会让置信度门控隐藏起来

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia