资讯详情

LangChain Function Calling原理与实战:从bind_tools到Agent循环

📅 2026/10/3 19:01:39 | 华诺云谱 👁 阅读
LangChain Function Calling原理与实战:从bind_tools到Agent循环
2023年我第一次认真写Agent的时候最头疼的不是模型本身而是怎么让模型稳定输出一段我能解析的JSON。ReAct那套Thought/Action/Observation提示词在demo里很好用一旦到了线上模型输出飘一下、JSON里多个逗号、或者把动作写进解释性文字里我的解析层就开始各种报错。后来OpenAI推出Function CallingLangChain也顺势把它封装成一套统一的工具调用范式我才彻底扔掉那堆正则和JSON修复逻辑。这篇文章想一次性把LangChain里Function Calling的实现原理、调用流转过程和我实际踩过的坑讲清楚给正在做Agent开发的朋友一份能直接参考的实践手册。如果你还停留在“让模型输出JSON然后代码去解析”的阶段或者已经在用LangChain但说不清bind_tools和tool_calls背后的链路这篇文章值得读完。我会从最底层的API长什么样开始一路拆到AgentExecutor的循环逻辑再用一个贴近业务的例子走完整条流程。1. 从手写JSON解析到Function CallingAgent工具调用的范式切换1.1 ReAct时代我为什么总觉得Agent不稳定在Function Calling出现之前业内主流的Agent写法是ReAct模式。简单说就是让模型按思考、行动、观察三个环节循环先让模型根据用户问题想一下该做什么然后生成一个动作指令程序拿到指令去调用工具再把工具结果作为“观察”喂回给模型继续下一轮思考。这个思路本身没毛病但它有一个非常致命的隐患模型输出的是自然语言而不是结构化数据你得自己想办法从一堆文字里把动作类型和动作参数抠出来。我当时最痛苦的场景有三类。第一模型在输出动作时夹带解释比如“好的我现在需要查一下北京的天气”后面才跟json。第二模型把JSON包在Markdown代码块里要额外处理json和这类标记。第三JSON本身不合法多了末尾逗号或空字段json.loads直接抛异常。为了应付这些情况我在代码里写了三层兜底第一层是正则抽JSON第二层是抽不出来就重试一次第三层是让模型把参数改写到固定模板里重试。这套方案在测试集上能到90%的通过率但剩下的10%脏数据上了线就是事故排查起来还特别费劲因为你不知道问题到底出在模型跑偏还是我的解析正则写错了。1.2 ReAct白白烧掉了大量token和轮次另一个容易被忽略的问题是ReAct的中间循环会把历史推理过程全部塞回上下文。理论上模型需要看到之前的Thought和Observation才能做下一步决策但这意味着每一轮工具调用都要重复读一遍前面所有的输出。用久了你会发现一个现象同一个任务如果工具调用超过三轮上下文里大部分都是历史记录真正新的信息没多少费用和延迟都跟着涨。我还遇到过更离谱的情况模型在历史Observation里找到了一个看似相关的字段下一轮就开始引用它但实际上那个字段根本不是本轮需要的。上下文越长这种“注意力漂移”发生的概率就越高。所以到后期我对ReAct的容忍度越来越低一直在等一个更结构化的方案。1.3 Function Calling真正改变的是什么Function Calling其实是把“工具调用”从文本生成任务里抽离出来变成了模型的另一条输出通道。模型判断需要调用工具时会返回一个结构化的tool_calls里面直接包含函数名和参数对象不再是一段需要你猜的文本。对应用层来说最大的好处是省掉了所有解析模型输出的工作。你可以直接用response[tool_calls][0][function][arguments]拿到一个合法的JSON字符串然后扔给json.loads就行。而LangChain在这个基础上做的事情是帮你把整个往返过程组织成一个Agent循环你定义好工具绑定到模型上剩下的调用、结果回传、继续推理都由框架来处理。这也是为什么后来几乎所有主流Agent框架都把Function Calling当作默认交互方式——它确实把“模型怎么用工具”这个核心问题解决得比较干净。2. 拆开LangChain的壳bind_tools背后发生的四件事2.1 OpenAI的tools参数到底长什么样要理解LangChain的封装得先看原生API的请求体。OpenAI在Chat Completions接口里增加了一个tools参数每个工具是一个对象包含type、function、name、description、parameters几个关键字段。parameters必须是JSON Schema格式用来描述函数接受哪些参数、每个参数的类型和含义。拿一个最简单的天气查询工具举例请求体里的tools会是这样{ type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位 } }, required: [city] } } }实际开发中最容易出错的就是这里。JSON Schema虽然格式完整但手写容易漏字段、写错描述而且当工具数量超过五六个之后手写定义根本维护不了。你在代码里改了一个参数名可能忘了同步修改JSON Schema模型就会一直按旧参数名去填线上日志里全是参数校验失败的记录。2.2 bind_tools做了什么从Python函数到JSON Schema的转换LangChain的bind_tools就是把“Python函数转成JSON Schema”这个工作自动化了。你可以传普通函数、带Pydantic模型的函数、或者BaseTool实例给它框架会在底层统一转换成OpenAI需要的tools格式。我实际项目里一般这么写from langchain_core.tools import tool tool def get_weather(city: str, unit: str celsius) - str: 查询指定城市的实时天气 return f获取到{city}的天气数据温度24度 llm_with_tools llm.bind_tools([get_weather])tool装饰器会把get_weather变成一个BaseToolbind_tools在底层调用_convert_to_openai_function这类转换器把所有工具统一转成OpenAI认识的JSON Schema。这部分的源码逻辑核心就是“反射加类型标注”LangChain检查函数的参数名、类型注解、默认值再结合docstring生成description。如果你用Pydantic模型当参数它会把Pydantic的Field信息映射成JSON Schema的字段描述字段约束也会一并带上。所以你在代码里写的是一个普通的Python函数发给OpenAI的却是严格的JSON Schema。这个转换过程透明但非常重要理解它你才能控制模型到底能看到什么样的参数定义。2.3 返回值流转从OpenAI响应到LangChain的Agent循环函数绑定上去之后模型的返回也需要解析。OpenAI响应里如果有tool_callsLangChain会把它们封装进AIMessage放在AIMessage.tool_calls属性里。tool_calls里每个元素包含两个关键信息函数名name和参数字典args。接下来是AgentExecutor的循环逻辑。它分两步第一步是Agent规划让模型判断下一步动作产出一个AIMessage。第二步是执行拿到AIMessage里的tool_calls后逐个调用对应的工具把工具返回的结果封装成ToolMessage再追加到对话历史里交给模型做下一轮规划。整个过程会一直重复直到模型决定不再调用任何工具直接输出最终答案。这里有个细节容易忽略LangChain把“模型决策”和“工具执行”拆成了两个独立环节。这意味着工具执行出错时错误信息也只是一条普通的ToolMessage并不会自动终止循环。如果工具一直失败模型可能一直重试同一个工具直到撞上max_iterations上限。这个特性我在后面的踩坑部分会详细说。3. 实战写一个能查天气、能回复工单的Agent3.1 设定场景两个工具一条链路与其讲一个孤立的demo不如直接来个贴近业务的场景。假设我们要做一个客服助手用户会问“北京今天适合跑步吗”或者直接说“我的订单一直没到帮我反馈一下”。前者需要查天气后者需要建支持工单。我定义两个工具get_weather_by_city输入城市和可选单位返回天气信息。create_support_ticket输入用户邮箱、问题描述和紧急程度在系统里创建一张工单。create_support_ticket要写入数据库参数必须收得很紧所以我给它定义了Pydantic输入模型import random from pydantic import BaseModel, Field, EmailStr from langchain_core.tools import tool class CreateSupportTicketInput(BaseModel): email: EmailStr Field(description用户联系邮箱) description: str Field(description问题描述尽量保留用户原话) priority: str Field( defaultmedium, description优先级low/medium/high, pattern^(low|medium|high)$ ) tool(args_schemaCreateSupportTicketInput) def create_support_ticket(email: str, description: str, priority: str medium) - str: 用于为用户创建支持工单 ticket_id fTKT-{random.randint(1000, 9999)} return f工单已创建编号{ticket_id}我们会尽快跟进这里用args_schema显式指定输入模型比直接靠函数签名更可控。因为邮箱格式、优先级枚举这些约束只有写进schema模型才不会随机乱填。经验是涉及外部系统写入的操作类型的严格程度直接决定你后续数据清洗的工作量。天气查询工具就简单一些本身是只读操作tool def get_weather_by_city(city: str, unit: str celsius) - str: 查询指定城市的实时天气 weather_data { 北京: 晴25度湿度40%, 上海: 多云28度湿度65%, 广州: 小雨30度湿度85%, } data weather_data.get(city, f暂无{city}的天气数据) return f{city}天气{data}单位{unit}3.2 组装Agent两种主流写法的选择第一种是用LangChain经典的AgentExecutor加create_openai_functions_agent适合大多数快速原型和结构固定的业务import os from langchain_openai import ChatOpenAI from langchain.agents import AgentExecutor, create_openai_functions_agent from langchain.prompts import ChatPromptTemplate os.environ[OPENAI_API_KEY] 你的API Key llm ChatOpenAI(modelgpt-4o-mini, temperature0) prompt ChatPromptTemplate.from_messages([ (system, 你是一个客服助手能查询天气、创建支持工单。), (human, {input}), (placeholder, {agent_scratchpad}), ]) tools [get_weather_by_city, create_support_ticket] agent create_openai_functions_agent(llm, tools, prompt) executor AgentExecutor(agentagent, toolstools, max_iterations5) result executor.invoke({input: 北京今天25度适合跑步吗}) print(result[output])第二种是LangGraph的StateGraph写法。同样一套工具和模型但流程控制权显式交给图结构。这里我贴一个最小化的示例展示工具节点和模型节点如何交替from typing import TypedDict from langgraph.graph import StateGraph, END from langchain_core.messages import ToolMessage class AgentState(TypedDict): messages: list def call_model(state): response llm.bind_tools(tools).invoke(state[messages]) return {messages: [response]} def execute_tools(state): last_message state[messages][-1] tool_messages [] for tc in last_message.tool_calls: tool {t.name: t for t in tools}[tc[name]] result tool.invoke(tc[args]) tool_messages.append(ToolMessage(contentresult, tool_call_idtc[id])) return {messages: tool_messages} graph StateGraph(AgentState) graph.add_node(agent, call_model) graph.add_node(tools, execute_tools) graph.add_edge(tools, agent) graph.set_entry_point(agent) graph.add_conditional_edges( agent, lambda state: tools if state[messages][-1].tool_calls else END, ) app graph.compile()这两种写法都能跑通同一个场景区别在于控制粒度。AgentExecutor把“规划-执行-重复”全部封装在一个黑盒里省事但中间环节不可控LangGraph把模型节点和工具节点拆开你能在中间插入人工审核、条件分支、状态回退。3.3 执行链路和日志解读跑通上面的代码后建议你打开executor内部日志实际流程是这样的用户输入“北京今天25度适合跑步吗”进入Agent。模型判断需要调get_weather_by_city返回tool_calls参数是{city: 北京}。LangChain执行工具得到“北京天气晴25度湿度40%”。系统把工具结果封装成ToolMessage喂回给模型。模型结合天气数据输出最终回答“适合跑步温度适宜”。排查问题的时候我一般直接在代码里打印中间消息列表而不是猜问题的原因。你真正想看的就两个东西模型决策时返回的tool_calls里有没有错误的参数以及工具执行后返回的ToolMessage内容是否符合预期。4. 踩坑记录Function Calling在真实项目里的六个“想不到”4.1 tool_choice强制指定却不生效有段时间我需要某个场景下只调用天气工具不让模型自己决定。于是就在bind_tools里传了tool_choice参数指定{type: function, function: {name: get_weather}}。但线上总有几次请求没有走这个工具而是直接用模型回答。排查链路我建议这样走先打印实际发给模型的tools参数确认tool_choice有没有真正传进去。再看LangChain版本旧版本对tool_choice的处理逻辑有差异。最后看模型规格gpt-3.5-turbo早期版本对tool_choice的支持不如gpt-4o稳定。我当时查到最后发现是部分请求走了老的模型规格把请求路由统一切到gpt-4o-mini之后问题消失。所以遇到这类问题先别急着怀疑LangChain先确认模型版本是否一致。4.2 Pydantic描述太简略模型会自己“脑补”参数最典型的例子我给城市参数写了个description城市名没有限定语言和表达方式。结果模型在部分请求里输出了拼音比如把“北京”写成“beijing”工具收到之后在天气映射表里查不到数据。后来我把所有参数描述都改成了带约束、带示例的写法比如description城市中文名称例如北京、上海、广州同时在工具内部再做一层校验必要时自动把拼音映射回中文。给大模型写参数描述其实和给新同事写交接文档一样你越具体它越不会自由发挥。描述写得好能省掉后面大量的数据清洗工作。4.3 多个并行tool_calls的执行顺序问题OpenAI的模型在支持multi-tool调用之后一次请求可能返回多个tool_calls。LangChain默认会并行执行这些工具。对于查天气、查库存这种无状态查询来说没问题但如果有步骤依赖关系比如先要建工单、再用工单号去发邮件并行执行就会把依赖打乱。我处理这类依赖问题时有三个办法第一在工具内部把依赖关系合并成一个工具比如“创建工单并发送通知邮件”第二用LangGraph把有先后顺序的操作拆成两个显式节点第三如果必须在AgentExecutor里做就自己实现一个串行执行器在ToolMessage里传递上下文。方法一最省事也是我现在默认的方案。4.4 bind_functions和bind_tools混用导致报错老项目里不少代码是bind_functions新写法是bind_tools。两者底层转换的schema基本一致但接口参数和返回结构在某些版本里有细微差异。我遇到过bind_functions写好的工具定义换了模型后偶尔报参数校验失败改成bind_tools后恢复正常。现在新项目我统一用bind_tools老代码留一个兼容层不让两种写法混在同一个Agent里。迁移成本其实很低bind_tools接受的对象范围更大Pydantic模型、BaseTool、普通函数都能处理建议尽早统一。4.5 Agent无限循环撞上max_iterations才停AgentExecutor默认的max_iterations值不小一旦模型在同一个问题上反复调用同一个工具就会白白消耗大量API费用。有一次线上工具返回“查询失败请重试”模型就开始反复调用直到撞上max_iterations上限才停下来。我的对策有两层。第一在工具内部对失败情况做完整归因返回给模型的Observation里要说明失败原因和替代建议不让模型“盲试”第二把max_iterations调到3到4宁可让任务失败返回人工也不要让它烧token控制长尾成本。4.6 非OpenAI模型的兼容性问题Function Calling早就不只是OpenAI独有Claude、Gemini、国产模型也都支持了但各家协议细节并不一致。LangChain在这里的价值很突出你面向ChatOpenAI写好的Agent换个模型供应商时大部分代码不用改只需要换一下模型初始化类。但LangChain并不是万能银弹。有些模型对工具描述的理解能力差异很大尤其是长描述和复杂的嵌套对象参数。我的建议是工具描述越长越要注意不同模型对长描述的处理差异切换模型后一定要专门做一轮工具调用回归测试覆盖每个工具的调用成功率和参数正确率。5. 更进一步从LangChain到LangGraph工具调用还够用吗5.1 AgentExecutor与LangGraph的执行差异AgentExecutor是一套轮询式的循环对大多数简单Agent够用。但它的流程是固定的规划、执行、再规划、直到结束。如果你想在中间插入一个“人工审核通过后再执行”的节点或者某个分支需要先执行A、再根据结果决定是否执行BAgentExecutor就不太顺手了。LangGraph则把流程建模成一张有向图每个节点是一个计算步骤边表示状态转换。这让中途打断、回退、人工介入都变得非常自然。我现在的实践是工具定义依然用LangChain的tool装饰器和Pydantic参数模型但循环控制权从AgentExecutor移交到LangGraph的StateGraph。状态里记录messages和当前步骤让每一步决策都是显式的出问题直接看哪个节点断了就行。5.2 关于“LangChain和LangGraph是不是都过时了”的讨论这个问题我在不少技术社区看到过问“LangChain和LangGraph是不是都过时了那我们用什么”我的看法是框架更新确实很快新框架也在不断出现OpenAI自己也陆续放出了AgentKit还有Codex这类coding agent。但有一个事实值得注意不管是LangChain、LangGraph还是新出的框架底层都还是“模型根据用户请求生成工具调用、系统执行工具、再把结果反馈给模型”这条老路。所以与其纠结框架选型我更建议把这条链路本身吃透。你理解了tool_calls怎么生成、结果怎么回流换任何框架都只是换个写法核心原理不变。框架帮你提效率但原理是根原理通了才能快速适应变化。5.3 Function Calling在新一代接口里的演进顺着这个思路再说深一层。OpenAI这两年也在持续推进新接口形态从Chat Completions向Responses API演进LangChain内部也做了适配。在Responses API里工具调用被抽象成更统一的tool调用对象但本质上依然是模型生成函数名和参数只是传输层协议换了。同样地OpenAI Codex这类coding agent的核心也还是工具链路模型决定调用哪些工具执行环境去运行代码、查看结果、再喂回给模型。未来的Agent会在工具粒度、上下文管理、多Agent协作这些层面继续演进但“模型加工具”的基本交互方式大概率还会长期存在。做技术选型时看长线就别只看某个框架的新旧看它背后的交互范式有没有跨越周期的生命力。最后分享一个我自己的习惯拿到一个新的Agent需求我会先用裸API把工具定义、tool_calls解析、结果回传这条完整链路跑通确认模型表现稳定之后再套LangChain或LangGraph的封装去写业务逻辑。这样做的好处是一旦出问题你能分清楚到底是模型行为异常还是框架处理有差异而不是在多层封装里瞎猜。这篇文章里的原理拆解和踩坑记录都是我实际趟过的路希望帮你少走一点我已经走过的弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑