mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:41:59 +00:00
c02ef189bb
起因是一条没人看得懂的报错:
工具 db_query__shop 执行失败:
_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'
参数名写成了 query(它叫 sql),而 call_tool 对动态工具是裸 **args 展开的,
跳过了 args_schema —— pydantic 那句"缺少 sql 字段"漏成了只剩内部闭包名的
TypeError,既指不到是哪个工具,也说不出该填什么。同一个函数里内置工具那条
分支本来是校验的,两条路径不一致。
registry 里补上 prepare_args / describe_args,两条路径共用:
- 候选唯一时(多一个不认识的、少一个必填的)改名重试,并发一条 warn 说破
—— 配置里那个错名还在,替它跑通一次却不吭声,下次照样踩
- 有歧义就不猜,报错改成"参数不对:query 不是它的参数;缺少必填参数 sql。
它接受:sql(必填,字符串)— 要执行的 SQL…",模型照着这句就能改对。
这才是失败之后真正能自愈的重试
- 顺带堵了一类静默失败:pydantic 默认把认不出的键悄悄丢掉,全可选 schema 上
{"tabel": "orders"} 会"成功"执行一次什么都没查的调用
agent 循环默认改成串行:一轮只调一个工具,看到结果再想下一步。并行更快,但
一批调用中间没有任何新的思考——第二个是照着"还没看到第一个结果"时的判断发出
去的。节点上留了「并行执行工具」开关。两个必须守住的点:没执行的调用绝不发
tool.start(前端靠 call_id 配对,发了没结尾的 start 会永远转圈),但必须各有
一条配对的 tool_result(少一条下次调用直接 400)。
交错思考跟着补上。4.6 起 adaptive thinking 自带交错,不需要也不该挂 beta
header(Opus 4.6 上手动模式压根没有交错);4/4.5 那一代要手动开 thinking +
interleaved-thinking beta,光挂 header 是一条什么都不做的空设置。开了 thinking
就不能再传 temperature,这一代本来是收的,漏了就是 400。
还有两处是串行带出来的,一并治了:
- 步数用完时收尾取的是 messages[-1],而那往往是给模型看的 ToolMessage。
真漏到用户面前当过答案(run dd9927e6):一句"本轮只执行了第一个工具"冒充
结论。改成只取模型自己说过的话,并把"步数用完了、去调大最大步数"附在后面
- db_schema 一次能问多张表。一次调用就是一步,逐张问 6 张表就是 6 步——
实测 8 步的运行里 7 步花在这上面,最后只剩一步真查数据
155 lines
6.9 KiB
Python
155 lines
6.9 KiB
Python
"""思考内容的提取。
|
|
|
|
界面上"它大致在想什么"只有这一个来源。两家的形态完全不同,漏掉任何一种,
|
|
那一侧的模型在编排期就只剩一行不动的"正在理解需求"——而它其实想了 50 秒。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
|
|
|
from app.engine.state import message_text, thinking_text
|
|
|
|
|
|
def test_anthropic_thinking_blocks() -> None:
|
|
msg = AIMessage(content=[
|
|
{"type": "thinking", "thinking": "先看看有哪些表。"},
|
|
{"type": "text", "text": "查到 3 张表。"},
|
|
{"type": "thinking", "thinking": "再按月份聚合。"},
|
|
])
|
|
assert thinking_text(msg) == "先看看有哪些表。再按月份聚合。"
|
|
# 正文不能把思考混进来
|
|
assert thinking_text(msg) not in message_text(msg)
|
|
assert "查到 3 张表。" in message_text(msg)
|
|
|
|
|
|
def test_openai_compatible_reasoning_content() -> None:
|
|
"""DeepSeek / Qianfan / vLLM 这类兼容端点把思考放在 reasoning_content。
|
|
|
|
langchain 的 ChatOpenAI 明确不保留这个字段("Non-standard response fields
|
|
added by third-party providers are not extracted or preserved"),
|
|
providers/factory.py 子类化捞了回来。
|
|
"""
|
|
chunk = AIMessageChunk(content="", additional_kwargs={"reasoning_content": "先看看现有的节点。"})
|
|
assert thinking_text(chunk) == "先看看现有的节点。"
|
|
|
|
|
|
def test_merged_chunks_keep_whole_reasoning() -> None:
|
|
"""流式合并后思考要连成一整段,不能只剩最后一片。"""
|
|
parts = ["用户想要", "一个能查销量的流程。", "先看看现有的节点。"]
|
|
merged = AIMessageChunk(content="", additional_kwargs={"reasoning_content": parts[0]})
|
|
for p in parts[1:]:
|
|
merged = merged + AIMessageChunk(content="", additional_kwargs={"reasoning_content": p})
|
|
assert thinking_text(merged) == "".join(parts)
|
|
|
|
|
|
def test_plain_message_has_no_thinking() -> None:
|
|
"""不产思考的模型不该被硬凑出一段来。"""
|
|
assert thinking_text(AIMessage(content="就是 2")) == ""
|
|
assert thinking_text(AIMessageChunk(content="x", additional_kwargs={})) == ""
|
|
|
|
|
|
def test_empty_reasoning_is_not_reported() -> None:
|
|
"""空串不算思考,否则界面上会出现一行什么都没有的"思考"。"""
|
|
assert thinking_text(AIMessageChunk(content="", additional_kwargs={"reasoning_content": ""})) == ""
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 交错思考:工具结果回来之后还能再想一段
|
|
# --------------------------------------------------------------------------
|
|
#
|
|
# 串行跑工具时这一条最要紧——每一步之间本来就该重新判断一次"看到这个结果
|
|
# 之后下一步该干嘛"。没有它,模型只在一轮开头想一次,后面全是埋头执行。
|
|
|
|
|
|
def _anthropic(model: str, **spec_kw):
|
|
import os
|
|
|
|
from app.db.models import Provider
|
|
from app.providers.factory import ModelSpec, build_chat_model
|
|
|
|
os.environ.setdefault("ANTHROPIC_API_KEY", "test-only")
|
|
provider = Provider(name="t", kind="anthropic", api_key=None, models=[],
|
|
extra=spec_kw.pop("extra", {}) or {})
|
|
return build_chat_model(provider, ModelSpec(model=model, **spec_kw))
|
|
|
|
|
|
def test_adaptive_models_need_no_beta_header() -> None:
|
|
"""4.6 起 adaptive thinking 自带交错。
|
|
|
|
这里不是"忘了加"——在 Opus 4.6 上手动模式压根没有交错思考,只有 adaptive
|
|
有,所以多挂一个 header 不但没用,还会把调用改道 client.beta.messages。
|
|
"""
|
|
for model in ("claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-sonnet-4-6"):
|
|
chat = _anthropic(model)
|
|
assert chat.betas is None, model
|
|
assert chat.thinking == {"type": "adaptive", "display": "summarized"}, model
|
|
|
|
|
|
def test_the_previous_generation_gets_the_beta_header() -> None:
|
|
"""Claude 4 / 4.5 要显式开。目录里没列它们,但模型名是放行的(可以手填)。"""
|
|
for model in ("claude-opus-4-5", "claude-sonnet-4-5", "claude-opus-4"):
|
|
assert _anthropic(model).betas == ["interleaved-thinking-2025-05-14"], model
|
|
# openrouter 那种带前缀的写法也要认得出来
|
|
assert _anthropic("openrouter/anthropic/claude-sonnet-4-5").betas == [
|
|
"interleaved-thinking-2025-05-14"
|
|
]
|
|
|
|
|
|
def test_the_beta_header_comes_with_thinking_actually_enabled() -> None:
|
|
"""光挂 header 是一条什么都不做的空设置。
|
|
|
|
这一代默认根本没开 thinking——没有思考可交错,header 挂了也白挂。
|
|
第一版就是这么写的:betas 有了,thinking 是 None。
|
|
"""
|
|
chat = _anthropic("claude-sonnet-4-5")
|
|
assert chat.thinking == {"type": "enabled", "budget_tokens": 4096}
|
|
assert chat.betas == ["interleaved-thinking-2025-05-14"]
|
|
|
|
|
|
def test_thinking_and_temperature_never_go_out_together() -> None:
|
|
"""开了 thinking 再传 temperature 就是 400。
|
|
|
|
4.6 起的模型压根不收采样参数,supports_sampling 已经挡住了;漏的正是
|
|
4/4.5 这一代——它们本来是收 temperature 的。
|
|
"""
|
|
assert _anthropic("claude-sonnet-4-5", temperature=0.7).temperature is None
|
|
# 没开 thinking 的还照常收
|
|
assert _anthropic("claude-haiku-4-5", temperature=0.7).temperature == 0.7
|
|
assert _anthropic("claude-sonnet-4-5", thinking="off", temperature=0.7).temperature == 0.7
|
|
|
|
|
|
def test_models_without_the_feature_are_left_alone() -> None:
|
|
"""Haiku 4.5 不支持交错思考,给它挂 header 只是噪音。"""
|
|
assert _anthropic("claude-haiku-4-5").betas is None
|
|
|
|
|
|
def test_thinking_off_means_no_interleaving_either() -> None:
|
|
"""都不让它想了,就别再开"想得更碎"的开关。"""
|
|
assert _anthropic("claude-sonnet-4-5", thinking="off").betas is None
|
|
|
|
|
|
def test_a_gateway_that_rejects_the_beta_can_opt_out() -> None:
|
|
"""自建网关可能不认这个 beta,而它是在请求时才炸的——留一个关得掉的口子。
|
|
|
|
关掉的只是交错,思考本身要留着:想要"别想了"的人写的是 thinking=off。
|
|
"""
|
|
chat = _anthropic("claude-sonnet-4-5", extra={"interleaved_thinking": False})
|
|
assert chat.betas is None
|
|
assert chat.thinking == {"type": "enabled", "budget_tokens": 4096}
|
|
|
|
|
|
def test_serial_tool_choice_stays_compatible_with_interleaving() -> None:
|
|
"""交错思考要求 tool_choice 是 auto 或 none。
|
|
|
|
串行模式给的是 {"type": "auto", "disable_parallel_tool_use": true} —— auto
|
|
不是强制调用,两者并存没问题。这条盯的是以后有人把它改成 any/tool:
|
|
那样会同时踩坏交错思考和 thinking 本身。
|
|
"""
|
|
from app.providers.factory import bind_tools_safely
|
|
from app.tools.registry import ToolContext, all_specs, build_tool
|
|
|
|
tools = [build_tool(next(iter(all_specs().values())), ToolContext())]
|
|
bound = bind_tools_safely(_anthropic("claude-sonnet-4-5"), tools, parallel=False)
|
|
assert bound.kwargs["tool_choice"]["type"] in ("auto", "none")
|