Skip to content

ToolStrategy 多结构化输出冲突与 ToolMessage 的流向 ​

整理自第 06 章「结构化输出」中使用 Union[ContactInfo, EventDetails] + ToolStrategy 时的重试现象。 涉及 LangChain 1.x 的 create_agent / ToolStrategy 行为;不同版本对 schema= 等参数可能有差异,以本地环境实测为准。

1. 一个现象 ​

用 Union[ContactInfo, EventDetails] 做结构化输出,输入文本里同时写了姓名、邮箱、活动名称、活动日期四段信息,运行后消息列表中出现了两条报错的 ToolMessage:

text
Error: Model incorrectly returned multiple structured responses (ContactInfo, EventDetails)
when only one is expected. Please fix your mistakes.

但最终 structured_response 是有结果的:

python
ContactInfo(name='张三', email='zhang3@xxt.com')

所以关键是先分清:这是中途重试,不是最终失败。

2. 完整执行轨迹(3 轮) ​

轮次模型动作产生的消息结果
1同时调用 ContactInfo 和 EventDetails1 条 AIMessage(含 2 个 tool_calls)+ 2 条错误 ToolMessage判为冲突
2按错误提示改为只调 ContactInfo1 条 AIMessage + 1 条成功 ToolMessage(提取完成!)通过
结束框架把解析结果写入 structured_response—✅

第 1 轮的模型输出形态:

python
AIMessage(
    content='',                       # 正文为空
    finish_reason='tool_calls',
    tool_calls=[
        {'name': 'ContactInfo',  'args': {'name': '张三', 'email': 'zhang3@xxt.com'},
         'id': 'call_XeQW...', 'type': 'tool_call'},
        {'name': 'EventDetails', 'args': {'event_name': '公司年会', 'date': '2026-07-15'},
         'id': 'call_dr2Ik...', 'type': 'tool_call'},
    ],
)

两个调用都在同一条 AIMessage 里,tool_calls 是长度为 2 的数组,每个调用各自有独立 id。

3. 两条 ToolMessage 是怎么来的 ​

3.1 直接原因:一轮返回了多个结构化结果 ​

Union 的语义是二选一,只允许出现 1 个结构化结果。模型一次给出 2 个,框架就得判冲突。两条错误消息分别对应那两个调用。

3.2 根因:ToolStrategy 的实现方式 ​

ToolStrategy 把每个 Schema 注册成一个“工具”,再解析模型发出的工具调用拿到结构化结果。它的校验规则是严格单结果:

  • 只接受 1 个结果;
  • 出现 2 个就抛 MultipleStructuredOutputsError;
  • handle_errors=True(默认)时,把错误写成 ToolMessage 回传,让模型重试。

框架内部的判定代码形如:

python
tool_names = [tc["name"] for tc in structured_tool_calls]
exception = MultipleStructuredOutputsError(tool_names, output)

错误文案即:

python
f"Model incorrectly returned multiple structured responses ({', '.join(tool_names)}) "
f"when only one is expected."

所以这不是模型出错,而是模型行为与框架的“只取一个”约束冲突。

模型这边:并行工具调用是 OpenAI 兼容接口的标准能力,默认开启,且两个调用的 args 完整正确 → 模型合规。 框架这边:Union 定义就是二选一 → 约束也合理。 两者组合 → 才判错。

4. 到底是谁在“报错” ​

判断依据:handle_errors=True 的行为是捕获错误并回写 ToolMessage,因此流程能继续走。

场景结果
handle_errors=True(默认)错误写入 ToolMessage,模型重试,最终可成功
handle_errors=False直接抛出异常
handle_errors="自定义文案"文案被替换,但语义上仍然是重试
handle_errors=(ValueError, TypeError)仅这些异常类型触发重试,其他照常抛出

想自定义不同错误的提示,可以传函数:

python
from langchain.agents.structured_output import (
    MultipleStructuredOutputsError,
    StructuredOutputValidationError,
)

def custom_error_handler(error: Exception) -> str:
    if isinstance(error, MultipleStructuredOutputsError):
        return "Multiple structured outputs were returned. Pick the most relevant one."
    if isinstance(error, StructuredOutputValidationError):
        return "There was an issue with the format. Try again."
    return f"Error: {str(error)}"

5. ToolMessage 是“传给模型的”,不是模型输出的 ​

这是最容易搞混的一点。消息流向:

text
HumanMessage        ← 用户
AIMessage           ← 模型(带 tool_calls)
ToolMessage         ← 框架 / 你的代码(工具结果或错误反馈)
→ 一起提交给模型,模型据此继续
消息类型由谁产生
HumanMessage用户
AIMessage模型
ToolMessage框架或你的代码;模型无法自己产生

两层含义:

  1. 它是对话里的一条消息,会随历史一起发给模型,模型读到“你只能选一个”才改正 → 这就是纠错闭环。
  2. 它是工具执行结果。写自定义工具时,函数返回值就是 ToolMessage 的 content:
python
@tool
def get_weather(city: str) -> str:
    return f"{city} 晴 25℃"    # ← 这个字符串会成为 ToolMessage 的 content

协议约束 ​

OpenAI 兼容接口要求:每条 tool_call 都必须有一条对应的 ToolMessage 回应,且 ToolMessage.tool_call_id 与 tool_calls[].id 一一匹配。

一旦模型发两个调用而只回一条,另一个 id 变成“孤儿”,下一轮请求会直接 400:

text
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'

自己手动拼多轮消息时,这条约束必须守住。

6. 想同时抽取两类信息,正确写法 ​

6.1 合并 Schema(推荐) ​

不要用 Union,把字段合到一个模型里,一次调用拿全:

python
from pydantic import BaseModel, Field
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy

class AllInfo(BaseModel):
    """综合信息"""
    name: str = Field(description="姓名")
    email: str = Field(description="电子邮箱")
    event_name: str = Field(description="活动名称")
    date: str = Field(description="活动日期")

agent = create_agent(
    model=model,
    tools=[],
    response_format=ToolStrategy(schema=AllInfo),
)

result = agent.invoke({
    "messages": [{
        "role": "user",
        "content": "请提取:姓名:张三,电子邮箱:zhang3@xxt.com,活动名称:公司年会,活动日期:2026-07-15",
    }]
})
print(result["structured_response"])

结果:只有 1 条 ToolMessage,1 次模型请求,无重试消耗。

6.2 保留 Union,但输入只描述一类实体 ​

python
content = "请提取以下文本中内容:姓名:张三,电子邮箱:zhang3@xxt.com"

文本里只有人 → 只会调 ContactInfo,不产生冲突。

6.3 加系统提示约束(概率性,不保证) ​

python
agent = create_agent(
    model=model,
    tools=[],
    system_prompt="每次只调用一个结构化输出工具,选择与用户文本最匹配的那个,不要同时调用多个。",
    response_format=ToolStrategy(
        schema=Union[ContactInfo, EventDetails],
        tool_message_content="提取完成!",
        handle_errors=True,
    ),
)

只是提高一次选对的概率,中转或不稳定模型上仍可能重试。

6.4 先分类再抽取(生产可用) ​

python
label = model.invoke(
    "下面文本主要描述的是「个人联系信息」还是「活动详情」?只回答 A 或 B。\n"
    "A=个人联系信息  B=活动详情\n"
    f"文本:{text}"
).content.strip()

schema = ContactInfo if label.startswith("A") else EventDetails
agent_one = create_agent(model=model, tools=[], response_format=ToolStrategy(schema=schema))

一次只给一个 Schema,从结构上消除多结果冲突,也省掉重试的 token。

7. 如果是“想执行两个动作”,走普通工具 ​

ToolStrategy 里“两条 ToolMessage”和“不报错”不可能同时成立。但若目标是一次调用两个函数、两条 ToolMessage 都是成功结果,那是普通 tools 的行为,与结构化输出无关:

python
@tool
def save_contact(name: str, email: str) -> str:
    """保存个人联系信息"""
    return f"已保存联系人:{name} / {email}"

@tool
def save_event(event_name: str, date: str) -> str:
    """保存活动详情"""
    return f"已保存活动:{event_name} / {date}"

agent = create_agent(model=model, tools=[save_contact, save_event])

预期得到两条成功 ToolMessage。适合“抽取后要落库/触发动作”的场景。

8. 速查表 ​

问题结论
两条 ToolMessage 是模型输出的吗不是,由框架或你的代码生成后回传给模型
模型一轮发两个调用算错吗不算,并行工具调用是标准能力,本次 args 完整正确
那报错来自哪Union 的“二选一”语义 + ToolStrategy 的单结果校验
报错后还能成功吗能。handle_errors=True 会把错误回写,模型重试
为什么两条都报错而不是只报一条两个结果都判为无效,各回一条,分别对应两个 tool_call_id
想同时抽两类字段合并成一个 Schema,别用 Union
想真正“两个 ToolMessage 且不报错”改用普通 tools,但那就不是结构化输出了
两边都想要用普通工具执行动作,抽取结果放工具返回值里,或分两步

9. 写 ToolStrategy 时容易踩的两个点 ​

python
# ❌ 少写参数名,部分版本会直接 TypeError
ToolStrategy(Union[ContactInfo, EventDetails])

# ✅ 必须带 schema=
ToolStrategy(schema=Union[ContactInfo, EventDetails])

另一个是:create_agent 建议显式传 tools=[],不要依赖隐式默认,避免和后续追加的工具混淆。

10. 验证结果是否真的成功 ​

不要只看中间那条 Error:,要看 structured_response:

python
from langchain_core.messages import ToolMessage

for msg in result["messages"]:
    if isinstance(msg, ToolMessage):
        print(msg.name, "->", msg.content)

print("\n最终结构化结果:", result.get("structured_response"))
  • structured_response 有值 → 成功,前面的 Error: 只是重试过程的一环;
  • structured_response 为 None 或抛异常 → 才需要继续排查(可能是递归上限被打满)。