LangChain 学习笔记(二):Tool Calling、ToolMessage 与结构化输出到底怎么串起来
LangChain 学习笔记(二):Tool Calling、ToolMessage 与结构化输出到底怎么串起来
这是 LangChain 学习笔记系列的第二篇。第一篇主要整理了
ChatModel、Message、PromptTemplate和 LCEL 调用链路。这一篇继续往下走,重点讲 LangChain 里非常核心的两件事:工具调用和结构化输出。
1. 为什么需要 Tool Calling
大模型本身最擅长的是:
理解自然语言 生成自然语言 总结、改写、推理、翻译但它本身不会真的做这些事情:
查数据库 搜索网页 读取本地文件 调用天气接口 发送邮件 执行计算函数 访问业务系统比如用户问:
北京今天的天气怎么样?模型如果只靠训练知识回答,就很容易过时。
再比如用户问:
帮我查一下订单 10086 的物流状态。模型自己并不知道你的订单系统在哪里,也不能直接查数据库。
所以 Tool Calling 要解决的问题是:
让模型负责判断“该调用什么工具、参数是什么”, 让程序负责真正执行工具, 再把工具结果交回模型生成最终回答。一句话:
模型负责决策和表达,代码负责执行真实动作。2. Tool Calling 的核心流程
先看整体流程:
用户提问 -> 模型判断是否需要工具 -> 模型返回 AIMessage,里面包含 tool_calls -> Python 程序读取 tool_calls -> 执行对应工具 -> 工具结果包装成 ToolMessage -> 把 ToolMessage 放回 messages -> 再次调用模型 -> 模型生成最终自然语言回答注意一个关键点:
AI 不直接执行工具。 AI 只生成 tool_calls。 真正执行工具的是 Python 程序。这也是很多初学者最容易混的地方。
3. 定义工具:@tool
最简单的工具定义方式是@tool。
fromlangchain_core.toolsimporttool@tooldefmultiply(a:int,b:int)->int:"""Multiply two numbers."""returna*b这个函数看起来只是普通 Python 函数,但加上@tool后,LangChain 会把它包装成模型可理解的工具。
工具函数最好包含:
函数名 参数类型注解 docstring 描述 返回值因为模型会根据这些信息判断:
这个工具是干什么的? 什么时候应该调用? 调用时需要传什么参数?例如:
@tooldefadd(a:int,b:int)->int:"""Add two integers."""returna+b这里的add、a、b、Add two integers.都会成为工具 schema 的一部分。
4. 定义工具:StructuredTool
如果工具参数比较复杂,或者想显式定义参数 schema,可以用StructuredTool。
frompydanticimportBaseModel,Fieldfromlangchain_core.toolsimportStructuredToolclassCalculatorInput(BaseModel):a:int=Field(description="第一个数字")b:int=Field(description="第二个数字")defmultiply(a:int,b:int)->int:returna*b calculator_tool=StructuredTool.from_function(func=multiply,name="Calculator",description="两数相乘",args_schema=CalculatorInput)StructuredTool的优势是:
参数结构更清晰; 字段描述更明确; 更适合复杂工具; 更方便模型理解和校验参数。简单工具用@tool就够了。
复杂工具、业务接口、参数较多的函数,更适合StructuredTool。
5. 绑定工具:bind_tools
工具定义好后,需要绑定到模型:
tools=[add,multiply]model_with_tools=model.bind_tools(tools)这一步很重要。
它不是执行工具,而是告诉模型:
你现在可以使用 add 和 multiply 这两个工具。所以:
model_with_tools=model.bind_tools(tools)返回的是一个新的 Runnable。
后面调用:
ai_msg=model_with_tools.invoke("2乘3等于多少?")模型可能不会直接回答,而是返回:
我要调用 multiply 工具,参数是 a=2, b=3。6.tool_calls是什么
tool_calls是模型返回的工具调用请求列表。
例如用户问:
2乘3等于多少?6加6等于多少?模型可能返回:
[{"name":"multiply","args":{"a":2,"b":3},"id":"call_abc123","type":"tool_call"},{"name":"add","args":{"a":6,"b":6},"id":"call_def456","type":"tool_call"}]字段含义:
name:要调用哪个工具 args:传给工具的参数 id:这次工具调用的唯一编号 type:类型,表示这是工具调用重点:
tool_calls 不是工具执行结果。 tool_calls 是模型生成的“工具调用计划”。真正执行工具的是后面的 Python 代码。
7. 完整工具调用代码
下面是一段完整流程:
fromlangchain_openaiimportChatOpenAIfromlangchain_core.messagesimportHumanMessagefromlangchain_core.toolsimporttool model=ChatOpenAI(model="gpt-4o-mini")@tooldefadd(a:int,b:int)->int:"""Add two integers."""returna+b@tooldefmultiply(a:int,b:int)->int:"""Multiply two integers."""returna*b tools=[add,multiply]model_with_tools=model.bind_tools(tools)messages=[HumanMessage("2乘3等于多少?6加6等于多少?")]ai_msg=model_with_tools.invoke(messages)messages.append(ai_msg)fortool_callinai_msg.tool_calls:selected_tool={"add":add,"multiply":multiply,}[tool_call["name"].lower()]tool_msg=selected_tool.invoke(tool_call)messages.append(tool_msg)final_msg=model.invoke(messages)print(final_msg.content)这段代码可以拆成四步:
第一步:用户提问 第二步:模型生成 tool_calls 第三步:程序执行工具,得到 ToolMessage 第四步:模型根据工具结果生成最终答案最终模型大概率会回答:
2乘3等于6,6加6等于12。8. 为什么要把ToolMessage放回 messages
这是工具调用里最关键的一点。
执行工具后,结果只存在于 Python 程序里:
tool_msg=selected_tool.invoke(tool_call)模型并不知道这个结果。
如果不把工具结果放回消息列表,模型只知道:
我刚才想调用 multiply 和 add。但它不知道:
multiply 返回了 6; add 返回了 12。所以必须:
messages.append(tool_msg)让模型在第二次调用时看到完整上下文:
HumanMessage("2乘3等于多少?6加6等于多少?") AIMessage(tool_calls=[...]) ToolMessage("6", tool_call_id="call_abc123") ToolMessage("12", tool_call_id="call_def456")这就是工具调用的标准协议:
HumanMessage -> AIMessage(tool_calls) -> ToolMessage -> AIMessage(final answer)tool_call_id的作用
ToolMessage里会带一个tool_call_id。
它的作用是:
告诉模型,这个工具结果对应前面哪一次工具调用。如果一次模型请求里产生多个工具调用,比如同时调用multiply和add,模型需要知道:
哪个结果是 multiply 的; 哪个结果是 add 的。这就是tool_call_id的价值。
9. TavilySearch:搜索工具的例子
工具不一定是数学函数,也可以是搜索工具。
比如使用 TavilySearch:
fromlangchain_tavilyimportTavilySearchfromlangchain_core.messagesimportHumanMessage tool=TavilySearch(max_results=4)model_with_tools=model.bind_tools([tool])messages=[HumanMessage("北京今天的天气怎么样?")]ai_msg=model_with_tools.invoke(messages)messages.append(ai_msg)fortool_callinai_msg.tool_calls:tool_msg=tool.invoke(tool_call)messages.append(tool_msg)final_msg=model.invoke(messages)print(final_msg.content)这里第一次调用模型时,模型可能生成:
我要调用 TavilySearch,搜索“北京今天的天气”。程序执行搜索后,得到搜索结果。
然后把搜索结果作为ToolMessage放回消息列表。
最后模型再根据搜索结果总结成自然语言。
这个流程非常接近真实 AI 应用:
模型判断要查什么 -> 工具查真实数据 -> 模型基于真实数据回答10.bind_tools里几个常见参数
bind_tools还有一些控制参数。
10.1tool_choice
控制模型是否必须使用工具。
常见值:
auto 自动选择,模型可以用也可以不用 none 不调用工具 any 强制至少调用一个工具 required 强制调用工具例如:
model_with_tools=model.bind_tools(tools,tool_choice="any")表示强制模型至少调用一个工具。
10.2parallel_tool_calls
控制是否允许并行工具调用。
如果用户一次问了两个问题:
2乘3是多少?6加6是多少?模型可能一次性生成两个工具调用:
multiply add这就是并行工具调用的场景。
10.3strict
strict=True时,会更严格要求模型输出符合工具 schema。
它适合对参数格式要求严格的业务场景。
11. 为什么还需要结构化输出
工具调用解决的是:
模型如何调用外部能力。结构化输出解决的是另一个问题:
模型输出如何变成程序能稳定使用的数据。普通模型输出可能是:
这个笑话的开头是……妙语是……我给 8 分。对人来说能看懂,但程序很难稳定处理。
我们更希望得到:
{"setup":"为什么程序员喜欢黑夜?","punchline":"因为黑夜里 bug 比较少。","rating":8}这样前端可以展示,后端可以入库,业务逻辑可以继续处理。
12. 用 Pydantic 定义输出结构
先定义一个数据模型:
fromtypingimportOptionalfrompydanticimportBaseModel,FieldclassJoke(BaseModel):"""给用户讲的一个笑话"""setup:str=Field(description="这个笑话的开头")punchline:str=Field(description="这个笑话的妙语")rating:Optional[int]=Field(default=None,description="从1-10分,给这个笑话评分")这里的Joke表示:
模型最终应该输出一个笑话对象; 对象里有 setup、punchline、rating 三个字段。字段含义:
setup:笑话开头,必须是字符串 punchline:笑话妙语,必须是字符串 rating:评分,可以是整数,也可以是 None注意:
Joke 只是结构定义。 Joke 本身不会调用模型,也不会自动约束模型输出。真正让模型按这个结构输出,需要 LangChain 的结构化输出能力或输出解析器。
13.with_structured_output
第一种方式是:
structured_model=model.with_structured_output(Joke)然后调用:
result=structured_model.invoke("讲一个关于程序员的笑话")print(result)返回结果可能直接是:
Joke(setup="为什么程序员喜欢黑夜?",punchline="因为黑夜里 bug 比较少。",rating=8)这种方式可以理解成:
把 Joke 这个 schema 绑定到模型上, 让模型按照结构化格式返回, LangChain 再把结果解析成 Joke 对象。它通常比纯 prompt 指令更稳,尤其是模型服务商支持 tool calling 或 JSON schema 的时候。
14.PydanticOutputParser
第二种方式是输出解析器:
fromlangchain_core.output_parsersimportPydanticOutputParser parser=PydanticOutputParser(pydantic_object=Joke)它拿着Joke做两件事。
第一,生成格式说明:
parser.get_format_instructions()这会生成一段提示词,大意是:
请按照下面的 JSON schema 输出: setup 是字符串 punchline 是字符串 rating 是整数或 null第二,解析模型输出:
chain=prompt|model|parser它会把模型返回的 JSON 字符串解析成Joke对象。
15. 为什么 prompt 里用了 parser,链尾还要放 parser
这是一个很容易混淆的问题。
示例代码:
prompt=PromptTemplate(template="回答用户问题。\n返回结构说明:{format_instructions}\n用户问题:{query}\n",partial_variables={"format_instructions":parser.get_format_instructions()},input_variables=["query"],)chain=prompt|model|parser看起来好像 parser 用了两次。
但这两次作用完全不同。
第一次:放进 prompt 里
parser.get_format_instructions()这里不是把 parser 本体放进去,而是生成一段格式说明文本。
作用是:
告诉模型应该按什么格式输出。它发生在模型调用之前。
第二次:放在 model 后面
chain=prompt|model|parser这里的 parser 是真正解析模型输出。
作用是:
把模型返回的 JSON 字符串解析成 Joke 对象。它发生在模型调用之后。
类比一下:
prompt 里的 format_instructions:考前答题要求 链尾的 parser:考后批改和整理答案如果只放格式说明,不放链尾 parser:
chain=prompt|model你拿到的可能只是:
AIMessage(content='{"setup": "...", "punchline": "...", "rating": 8}')它看起来像 JSON,但本质还是字符串。
如果只放链尾 parser,不给格式说明:
chain=prompt|model|parser但 prompt 里没告诉模型输出 JSON,模型可能输出一段自然语言,parser 就可能解析失败。
所以两步都需要:
format_instructions:提高模型按格式输出的概率 parser:把输出真正转成 Python 对象16.JsonOutputParser
JsonOutputParser用来解析 JSON 输出。
它可以不绑定 Pydantic,也可以配合 Pydantic 做验证。
例如:
fromlangchain_core.output_parsersimportJsonOutputParser parser=JsonOutputParser()适合场景:
只需要普通 JSON 字典 字段结构不算特别复杂 前后端接口需要 JSON如果配合 Pydantic:
parser=JsonOutputParser(pydantic_object=Joke)它也可以根据Joke做一定的结构约束。
17. Pydantic 和 JSON 的区别
简单理解:
JSON 是数据格式。 Pydantic 是数据模型 + 类型校验。如果只要:
{"name":"小明","age":18}用 JSON 就可以。
但如果你希望更严格:
name 必须是字符串 age 必须是整数 rating 可以为空 字段缺失时怎么处理 字段说明是什么那就更适合 Pydantic。
实际项目里:
简单结构:JsonOutputParser 强类型结构:PydanticOutputParser 或 with_structured_output18.with_structured_output和PydanticOutputParser怎么选
两种方式都能得到结构化结果,但机制不同。
with_structured_output
把 schema 绑定到模型上; 通常使用模型服务商支持的 tool calling / JSON schema 等能力; 更像模型原生结构化输出; 一般更稳。适合:
支持结构化输出的聊天模型; 希望直接得到 Pydantic 对象; 希望减少手动 prompt 控制。PydanticOutputParser
通过格式说明告诉模型怎么输出; 再用 parser 解析模型返回内容; 更偏 prompt 约束 + 后处理解析。适合:
想手动控制 prompt; 需要学习输出解析器原理; 模型不一定支持原生结构化输出; 想和 PromptTemplate 显式组合。一句话:
with_structured_output 更像“模型绑定 schema”; PydanticOutputParser 更像“提示模型按 schema 写,然后我来解析”。19. 工具调用和结构化输出的关系
这两个概念容易混在一起。
它们都可能使用 schema,但解决的问题不同。
Tool Calling: 解决“模型如何调用外部函数/工具”的问题。 结构化输出: 解决“模型最终回答如何变成结构化数据”的问题。例如:
用户问:北京今天适合跑步吗?可能流程是:
1. 模型调用 TavilySearch 查询天气 2. 程序执行搜索工具 3. 工具结果返回模型 4. 模型输出结构化建议: { "suitable": true, "reason": "...", "risk_level": "low" }这里同时用了:
Tool Calling:查天气 结构化输出:返回固定格式建议20. 初学者容易混淆的点
20.1tool_calls是不是工具结果?
不是。
tool_calls 是模型想调用工具的请求。 ToolMessage 才是工具执行结果。20.2 模型会不会自己执行工具?
不会。
模型只生成调用计划; Python 程序真正执行工具。20.3 为什么要把 AIMessage 也放回 messages?
因为ToolMessage必须对应前面的AIMessage(tool_calls)。
模型需要看到:
我刚才请求了哪些工具; 每个工具返回了什么。20.4Joke和PydanticOutputParser功能是不是重复?
不重复。
Joke:定义结构。 Parser:使用这个结构生成格式说明,并解析模型输出。20.5 prompt 里已经有格式说明,为什么链尾还要 parser?
因为格式说明只是告诉模型怎么写。
链尾 parser 才是真正把模型输出转成 Python 对象。
21. 真实项目里怎么用
这一篇的内容在真实项目里非常有用。
21.1 工具调用能做什么
AI 查询数据库 AI 搜索网页 AI 调用天气接口 AI 读取本地文件 AI 调用订单系统 AI 执行计算函数 AI 操作业务 API它是 Agent 的基础能力。
21.2 结构化输出能做什么
简历分析结果入库 客服意图分类 合同字段抽取 论文信息抽取 商品评论情感分析 RAG 回答结构化总结比如做一个简历分析助手,可以让模型输出:
{"name":"张三","skills":["Python","LangChain","Redis"],"match_score":82,"suggestions":["补充项目经历","加强 RAG 实践"]}这比返回一大段自然语言更适合程序处理。
22. 本篇总结
这一篇主要讲了两件事。
第一,工具调用:
模型生成 tool_calls; Python 执行工具; 工具结果变成 ToolMessage; 再交回模型生成最终回答。标准流程是:
HumanMessage -> AIMessage(tool_calls) -> ToolMessage -> AIMessage(final answer)第二,结构化输出:
用 Pydantic 或 JSON schema 规定模型输出结构; 用 with_structured_output 或 OutputParser 得到程序能直接处理的数据。几个关键结论:
tool_calls 是工具调用请求,不是结果。 ToolMessage 是工具执行结果。 tool_call_id 用来对应一次具体工具调用。 Joke 这类 Pydantic 类只定义结构,不负责调用模型。 PydanticOutputParser 负责生成格式说明和解析模型输出。 with_structured_output 更像把结构直接绑定给模型。一句话概括:
Tool Calling 让模型能连接外部能力; 结构化输出让模型结果能被程序稳定使用。23. 下一篇预告
下一篇会整理:
Few-shot、示例选择器与 RAG 检索重点回答这些问题:
Few-shot 为什么能让模型模仿格式和推理方式? FewShotPromptTemplate 和 FewShotChatMessagePromptTemplate 有什么区别? Length、语义相似、MMR、N-gram 示例选择器分别怎么选示例? RAG 为什么要先检索再生成? Retriever 和 VectorStore 是什么关系? MMR 在 RAG 里为什么能减少重复文档?也就是从“模型如何调用工具和稳定输出”继续走到“模型如何参考示例和外部知识回答”。