作者在 llama.cpp b11717 与 Qwen3-0.6B 组合中发现,Anthropic 兼容端点未正确执行指定工具及禁用工具的请求参数。停止字符串还会匹配推理内容,可能导致模型尚未作答就结束请求。
简要结论:llama-server 的 Anthropic 兼容端点 /v1/messages 接受两个请求控制参数,却没有实际应用。强制指定工具的 tool_choice: {"type": "tool", "name": "get_time"} 被当成了“调用某个工具”:在同时提供 get_weather 和 get_time、用户询问天气的情况下,模型调用了 get_weather。tool_choice: {"type": "none"} 则完全没有生效,模型再次调用了 get_weather。没有报错,也没有日志。另外,stop_sequences 不仅会匹配模型的回答,也会匹配模型的推理内容。因此,推理模型如果在思考时写出了停止字符串,请求就会在给出回答之前结束:返回的只有一个 thinking 块,stop_reason 为 end_turn,而 stop_sequence 则标出了匹配到的字符串。这两个问题都在 llama.cpp b11717 搭配 Qwen3-0.6B 时观察到了。停止行为与 10 月 16 日提交的 vLLM 问题报告 vllm-project/vllm#62085 表现一致;在那份报告中,它导致 Claude Code 的 auto 模式无法正常工作。我没有找到 llama.cpp 中关于这两个问题的报告。
测试环境是一个用完即弃的 LXC 容器、b11717 发布版二进制文件,以及 Qwen3-0.6B-Q8_0.gguf:
llama-server -m Qwen3-0.6B-Q8_0.gguf --jinja -c 4096 --port 18090
提供两个工具 get_weather(city) 和 get_time(city),用户消息为“What is the weather in Vienna?”(维也纳的天气怎么样?),设置 temperature: 0,并逐一测试文档中列出的所有 tool_choice 形式:
anthropic tool_choice=null: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "auto"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "any"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "tool", "name": "get_time"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "tool", "name": "get_weather"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
anthropic tool_choice={"type": "none"}: ([('tool_use', 'get_weather', '{"city": "Vienna"}')], 'tool_use')
六次请求返回的 stop_reason 都是 tool_use,工具调用也完全相同。按名称指定 get_time 没有改变结果,使用 none 也一样。服务器日志中没有任何一条与这些情况有关的记录。
同一台服务器上的 OpenAI 兼容端点也有类似问题,上游已经有人报告过(llama.cpp#30369):按名称指定函数的形式 {"type": "function", "function": {"name": "get_time"}} 最终调用了 get_weather,不过这一次日志明确记录了原因:
W Wrong type supplied for parameter 'tool_choice', using default value: [json.exception.type_error.302] type must be string, but is object
在这个端点上使用 tool_choice: "none" 时,服务器没有解析出工具调用,但模型仍然写出了工具调用,并把它作为文本放进了 content:'<tool_call>\n{"name": "get_weather", "arguments": {...'。
接下来,用一个会让 Qwen3 先思考的问题进行测试,并选择会出现在推理内容中的停止字符串。设置 temperature: 0.6,使用两个 seed,测试两个端点:
stop='answer should be' seed 1 OpenAI finish=stop content='' reasoning_len=1639 tail='...7 is the only number that can divide it. Therefore, the '
Anthropic stop_reason=end_turn stop_sequence='answer should be' blocks=[('thinking', 807, '...7 is indeed a prime number. Therefore, the ')]
stop='prime' seed 1 OpenAI finish=stop content='' reasoning_len=42 tail='Okay, so the question is asking if 7 is a '
Anthropic stop_reason=end_turn stop_sequence='prime' blocks=[('thinking', 40, 'Okay, so the question is whether 7 is a ')]
seed 2 的表现相同。推理恰好在停止字符串之前中断,回答要么为空(OpenAI),要么根本不存在(Anthropic 只返回一个 thinking 块,没有 text 块),而请求却报告正常结束。不设置停止字符串时,同样的请求会在生成约 1,660 个字符的推理内容后,回答 <block>yes</block>。
Anthropic 端点的响应还存在自相矛盾:stop_sequence 指出了匹配到的序列,但 stop_reason 却是 end_turn,而不是 stop_sequence。在另一个测试中,停止字符串 </block> 截断了可见回答,也出现了同样的情况。
每个响应的格式都是合法的。调用了错误工具,仍然是一次合法的工具调用。如果只定义了一个工具,或者模型恰好自己选中了被强制指定的工具,这个 bug 就完全看不出来。这里的 get_weather 也确实是回答用户问题的正确选择,所以强制工具测试之所以能暴露问题,是因为我们刻意强制指定了一个不适合该问题的工具。
停止字符串的问题看起来像是模型自身的毛病。空回答加上 finish_reason: stop,很容易被理解为“模型认为自己已经完成了”,而小型推理模型确实有时会絮絮叨叨,或者在奇怪的地方停下。唯一的线索是:推理在句子中途结束,而且恰好停在本该开始输出停止字符串的位置。
对于连接本地服务器的 Agent 工具,这两个问题尤其重要。Claude Code 可以连接任何 Anthropic 兼容端点。根据 vllm#62085,它的 auto 模式会发送一个带有 stop_sequences: ["</block>"] 的分类器请求。在我的测试中,Qwen3 从未在推理内容里写出 </block>,所以我没有在这里观察到那个具体请求失败。不过,只要停止字符串可能出现在模型的思考过程中,就会触发这个问题。
对于 tool_choice,llama-server 会先把 Anthropic 请求转换成内部使用的 OpenAI 风格格式,相关代码位于 tools/server/server-chat.cpp。查看源码中的转换逻辑(master 和 b10703)可以发现,{"type": "tool", "name": ...} 会变成 OpenAI 的 "required",工具名称被丢弃;而 {"type": "none"} 没有对应的映射,因此保留默认值 "auto"。"required" 的含义是“调用这些工具中的一个”,模型确实照做了,但没有任何信息告诉它应该调用哪一个。上面的测试结果与此吻合:any 和按名称指定工具的形式,行为完全相同。
对于停止字符串,服务器在 process_token 中检查时,匹配范围是该 slot 已生成的全部文本,具体调用是 tools/server/server-context.cpp 中的 find_stopping_strings(slot.generated.text, ...)。推理与回答的拆分发生得更晚,要等到构建响应时才进行。因此,无论停止字符串出现在生成文本的什么位置,包括推理部分,都会被匹配到;随后,服务器只根据停止字符串之前已经生成的内容构建响应。
Anthropic API 是否规定 stop_sequences 也应作用于 thinking,这是一个合理的问题,我还没有确认。但空回答显然不是客户端设置停止序列时想要的结果,而且,只要响应设置了 stop_sequence,就应该同时返回 stop_reason: "stop_sequence"。
这两个问题都没有可用的服务器配置项来解决。客户端可以采取以下措施:
tool_choice:不要依赖 /v1/messages 来强制指定工具。如果某一步必须使用某个特定工具,就在该次请求的 tools 中只提供这一个工具,并设置 {"type": "any"}。既然 any 确实能表达“调用某个工具”,这样应该就能强制调用唯一的工具,不过这里没有测试过。要阻止工具调用,应从请求中移除 tools,而不是发送 {"type": "none"}。
对推理模型使用 stop_sequences:选择模型在思考时不会写出的停止字符串,例如自定义输出格式的结束标签;或者,对依赖停止字符串的请求关闭推理。在 llama-server 中,--reasoning-budget 0 可以在整个服务器范围内关闭推理。本轮测试没有验证这一点。
检测这种失败:如果响应没有 text 块(或 content 为空),同时又设置了 stop_sequence,就应将其视为这里描述的失败,而不是一次空回答,并在移除停止字符串后重试。
工具包中的 check-llama-anthropic-controls.sh 会向服务器发送四个小请求,分别报告各个控制参数的行为。在这台服务器上,结果如下:
IGNORED tool_choice {type: tool, name: get_time} -> called ['get_weather']
IGNORED tool_choice {type: none} -> called ['get_weather']
MATCHED IN REASONING stop_sequences ['prime'] ended the reply inside thinking: blocks ['thinking'], stop_reason 'end_turn', stop_sequence 'prime'
note stop_sequence is 'prime' but stop_reason is 'end_turn'
退出码为 1。如果模型不调用工具,或者不进行推理(这里使用的是 gemma-3-1b),脚本会报告测试无法得出结论,并以退出码 2 结束,而不是判定通过。
兼容层本质上是一种翻译,而翻译可能悄无声息地丢失信息。当服务器声称支持另一家厂商的 API 时,要测试你真正依赖的参数,并且让测试中的正确结果与默认行为产生不同的输出:强制调用模型原本不会选择的工具,选择一个模型会在你不希望它停止的位置写出的停止字符串。如果正确行为和错误行为给出相同的输出,这样的测试证明不了任何事。
Ollama 上也出现过同样的模式:控制参数被接受,随后却被模型的推理悄悄覆盖——一个 thinking 模型在直接回答时跳过了 JSON schema。
本文对应的修复方案已收录进工具包,是一份经过测试、可以直接运行的脚本。
如需采取进一步行动,可以考虑屏蔽此人和/或举报滥用行为。