主题
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 和 EventDetails | 1 条 AIMessage(含 2 个 tool_calls)+ 2 条错误 ToolMessage | 判为冲突 |
| 2 | 按错误提示改为只调 ContactInfo | 1 条 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 | 框架或你的代码;模型无法自己产生 |
两层含义:
- 它是对话里的一条消息,会随历史一起发给模型,模型读到“你只能选一个”才改正 → 这就是纠错闭环。
- 它是工具执行结果。写自定义工具时,函数返回值就是 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或抛异常 → 才需要继续排查(可能是递归上限被打满)。