资讯详情

LangChain Tool Call 实战:从字符串协议到结构化调用

📅 2026/9/29 19:01:23 | 华诺云谱 👁 阅读
LangChain Tool Call 实战:从字符串协议到结构化调用
1. 从字符串到结构化调用LLM Tool Call 的演进逻辑大语言模型刚火起来那阵子大家最头疼的一件事就是模型能说会道但它没法真正“动手”。你问它今天天气怎么样它只能根据训练数据里的旧信息瞎编一个因为它没有实时查询的能力。后来有人想了个办法——让模型输出一段特定格式的字符串外部程序解析这段字符串执行对应的操作再把结果塞回给模型。这就是最早的 Tool Call 雏形说白了就是“模型负责说程序负责做”。这个思路听起来简单但实际落地的时候坑特别多。最直接的问题就是模型输出的字符串格式不稳定。你让它输出get_weather(cityBeijing)它可能给你输出get_weather(cityBeijing)也可能输出get_weather(Beijing)甚至有时候会加一句“好的我来帮你查询get_weather(cityBeijing)”。你得写一堆正则表达式去兼容各种奇葩输出维护成本极高。LangChain 这类框架的出现本质上就是把这个“字符串协议”标准化了。它定义了一套工具调用的接口规范模型不再直接输出自由文本而是输出结构化的 JSON 对象明确告诉外部程序我要调用哪个工具、传什么参数。框架负责解析这个 JSON执行对应的函数再把结果格式化后返回给模型。整个过程从“靠约定”变成了“靠协议”可靠性提升了一个量级。我刚开始接触这块的时候觉得这不就是个函数调用吗有什么难的。真正上手之后才发现从字符串协议到结构化调用的转变涉及的不只是格式问题还包括错误处理、参数校验、多轮对话中的上下文管理、并发调用的顺序控制等等。这些细节才是决定一个 Tool Call 系统能不能上生产环境的关键。提示如果你现在还在用正则表达式解析模型输出的工具调用请求建议尽早迁移到结构化协议。正则的维护成本会随着工具数量的增加呈指数级上升。2. 字符串协议时代的典型做法与致命缺陷2.1 早期字符串协议的常见实现方式在 LangChain 的 Tool Call 机制成熟之前社区里流行过好几种字符串协议方案。最常见的一种是“前缀标记法”在系统提示词里告诉模型如果你需要调用工具就以ACTION:开头输出工具名和参数以END_ACTION结尾。外部程序通过字符串匹配找到这段内容解析后执行。另一种是“JSON 嵌入法”要求模型在回复中嵌入一个 JSON 块比如{tool: search, params: {query: ...}}程序用 JSON 解析器提取这个块。这种方式比前缀标记法稍微靠谱一点因为 JSON 本身有明确的语法规则解析起来不容易出错。还有一种比较粗暴的做法是“函数签名匹配”直接让模型输出类似 Python 函数调用的字符串然后用eval或者ast.literal_eval去解析。这种做法极其危险因为模型可能输出恶意代码而且eval的性能和安全性都不适合生产环境。2.2 字符串协议为什么不可靠字符串协议最大的问题在于它依赖模型“自觉遵守格式”。但大语言模型的本质是概率生成它输出的内容是 token 序列的概率采样结果不是确定性的程序输出。你可以在提示词里反复强调“必须严格按照格式输出”但模型总有概率偏离。我实测过一个场景让模型调用一个查询天气的工具提示词里明确写了“输出格式必须是get_weather(city城市名)”。测试了 100 次大约有 87 次输出完全符合格式8 次用了单引号3 次在函数调用前后加了额外文字2 次把参数名写错了。这意味着如果你不做额外的容错处理大约有 13% 的请求会失败。这还只是单一工具的情况。当工具数量增加到十几个每个工具的参数结构都不一样时模型出错的概率会显著上升。更麻烦的是有些错误是“静默失败”——模型输出了格式正确的字符串但参数值是错的程序执行后返回了错误结果但整个流程没有报错你很难发现问题的根源。2.3 从字符串协议到结构化协议的关键转折转折点出现在 OpenAI 推出 Function Calling 功能之后。模型不再输出自由文本而是输出一个结构化的 JSON 对象包含name和arguments两个字段。name是工具名称arguments是一个 JSON 字符串里面是参数键值对。这个 JSON 对象由模型的推理引擎直接生成格式稳定性远高于自由文本。LangChain 很快跟进把这个机制封装成了bind_tools方法。你只需要把 Python 函数用tool装饰器标记一下LangChain 会自动提取函数的名称、参数类型、文档字符串生成模型能理解的工具描述。模型返回工具调用请求后LangChain 负责解析 JSON、校验参数、执行函数、把结果包装成ToolMessage返回给模型。这个转变的核心价值在于把格式约定的责任从提示词转移到了框架层。提示词里不再需要写“你必须按照某某格式输出”而是由框架在 API 层面约束模型的输出结构。模型厂商在训练阶段就已经让模型学会了这种结构化输出模式可靠性有本质提升。3. LangChain 工具调用的核心机制拆解3.1 工具定义从 Python 函数到模型可理解的描述LangChain 里定义一个工具最直接的方式是用tool装饰器。比如你要定义一个查询天气的工具from langchain_core.tools import tool tool def get_weather(city: str) - str: 查询指定城市的当前天气情况。 Args: city: 城市名称例如北京、上海 # 实际实现中这里会调用天气 API return f{city}今天晴气温 25 摄氏度这个装饰器做了几件事第一提取函数名get_weather作为工具名称第二提取参数city和它的类型标注str第三提取文档字符串作为工具描述第四把函数包装成一个StructuredTool对象这个对象有name、description、args_schema等属性。模型看到的工具描述大概是这样的{ name: get_weather, description: 查询指定城市的当前天气情况。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如\北京\、\上海\ } }, required: [city] } }这个 JSON Schema 就是模型决定是否调用工具、以及如何填充参数的依据。文档字符串写得越清晰模型判断越准确。我见过很多人随便写一句“查询天气”就完事了结果模型经常把城市名和日期搞混或者在不该调用的时候调用。3.2 工具绑定让模型知道有哪些工具可用定义好工具之后需要把它绑定到模型上from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-4o) tools [get_weather] llm_with_tools llm.bind_tools(tools)bind_tools做的事情是把工具列表转换成模型 API 能接受的格式然后在每次请求时把这些工具描述一起发给模型。模型在生成回复时会判断当前对话是否需要调用工具。如果需要它不会输出普通文本而是输出一个tool_calls字段里面包含工具名称和参数。这里有个细节值得注意bind_tools返回的是一个新对象原来的llm对象不受影响。这意味着你可以同时维护多个不同工具集的模型实例比如一个用于天气查询一个用于数据库操作互不干扰。3.3 调用解析从模型输出到实际执行当模型返回tool_calls后LangChain 的ToolNode或者手动处理逻辑会接管后续流程。一个典型的处理循环是这样的from langchain_core.messages import HumanMessage, ToolMessage messages [HumanMessage(content北京今天天气怎么样)] response llm_with_tools.invoke(messages) messages.append(response) if response.tool_calls: for tool_call in response.tool_calls: tool_name tool_call[name] tool_args tool_call[args] # 根据 tool_name 找到对应的工具函数并执行 tool_result tools_map[tool_name].invoke(tool_args) messages.append(ToolMessage( contentstr(tool_result), tool_call_idtool_call[id] )) # 把工具执行结果返回给模型生成最终回复 final_response llm_with_tools.invoke(messages)这个循环里最关键的是tool_call_id。每次模型发起工具调用时都会生成一个唯一的 ID工具执行结果必须带上这个 ID 返回模型才能把结果和请求对应起来。如果 ID 对不上模型会认为工具调用没有完成可能会重复发起调用。注意ToolMessage的tool_call_id必须和模型返回的tool_call[id]完全一致包括大小写和特殊字符。我踩过一次坑手动构造ToolMessage时把 ID 截断了结果模型一直重复调用同一个工具陷入了死循环。3.4 多工具并行调用的处理策略当模型一次性返回多个工具调用时LangChain 默认会按顺序执行。但在实际场景中如果这些工具之间没有依赖关系并行执行能显著降低延迟。比如用户问“北京和上海今天天气怎么样”模型会返回两个get_weather调用这两个调用完全可以并行。LangChain 的ToolNode支持并行执行底层用的是线程池。你只需要把工具列表传给ToolNode它会自动处理并发。但这里有个坑如果你的工具函数不是线程安全的比如共享了某个全局变量并行执行可能会出问题。我建议在工具函数内部避免使用全局状态所有依赖都通过参数传入。另一个需要注意的点是错误处理。如果并行执行的多个工具中有一个失败了ToolNode默认会把错误信息作为ToolMessage返回给模型让模型决定下一步怎么做。这个设计很合理因为模型可以根据错误信息判断是重试、换工具、还是直接告诉用户出错了。4. 实操从零搭建一个可用的 Tool Call 流程4.1 环境准备与依赖安装先确保 Python 环境是 3.9 以上版本然后安装必要的包pip install langchain langchain-openai langchain-core如果你用的是其他模型厂商比如 Anthropic 或者国内的通义千问需要安装对应的集成包。LangChain 的架构是模型无关的只要模型支持工具调用协议就可以用同一套代码。环境变量里配置好 API Keyexport OPENAI_API_KEY你的密钥我建议用python-dotenv管理环境变量避免把密钥硬编码在代码里。创建一个.env文件然后from dotenv import load_dotenv load_dotenv()4.2 定义一组有实际用途的工具为了演示完整流程我定义三个工具查询天气、查询汇率、计算表达式。from langchain_core.tools import tool import math tool def get_weather(city: str) - str: 查询指定城市的当前天气。 Args: city: 城市名称 # 模拟数据实际应调用天气 API weather_data { 北京: 晴25°C, 上海: 多云28°C, 广州: 小雨30°C } return weather_data.get(city, f未找到{city}的天气数据) tool def get_exchange_rate(from_currency: str, to_currency: str) - str: 查询两种货币之间的汇率。 Args: from_currency: 源货币代码如 USD to_currency: 目标货币代码如 CNY rates { (USD, CNY): 7.24, (EUR, CNY): 7.85, (JPY, CNY): 0.048 } rate rates.get((from_currency.upper(), to_currency.upper())) if rate: return f1 {from_currency} {rate} {to_currency} return f未找到{from_currency}到{to_currency}的汇率 tool def calculate(expression: str) - str: 计算数学表达式的结果。 Args: expression: 数学表达式如 2 3 * 4 try: # 限制可用的函数和变量防止安全问题 allowed_names {k: v for k, v in math.__dict__.items() if not k.startswith(_)} result eval(expression, {__builtins__: {}}, allowed_names) return str(result) except Exception as e: return f计算错误{e}这三个工具覆盖了不同的参数类型和返回类型能较好地测试 Tool Call 的各个环节。4.3 构建完整的调用链把工具绑定到模型然后构建一个带工具执行节点的图from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage from langgraph.prebuilt import create_react_agent llm ChatOpenAI(modelgpt-4o, temperature0) tools [get_weather, get_exchange_rate, calculate] agent create_react_agent(llm, tools) response agent.invoke({ messages: [HumanMessage(content北京今天天气怎么样另外帮我算一下 15 * 23 7)] }) for msg in response[messages]: print(f{msg.type}: {msg.content})create_react_agent是 LangGraph 提供的一个预构建 agent它内部自动处理了“模型调用→工具执行→结果返回→模型再调用”的循环。你不需要手动写 while 循环框架会一直执行直到模型不再请求工具调用为止。4.4 参数校验与错误处理的实际配置LangChain 的工具参数校验基于 Pydantic。当你用tool装饰器时它会根据函数签名自动生成 Pydantic 模型。如果模型传入了不符合类型的参数比如把字符串传给了期望整数的参数Pydantic 会抛出验证错误。这个错误会被 LangChain 捕获并作为ToolMessage返回给模型。模型看到错误信息后通常会尝试修正参数重新调用。我实测下来对于简单的类型错误模型自我修正的成功率在 80% 以上。但有些错误模型无法自我修正比如工具依赖的外部 API 挂了。这种情况下你需要在工具函数内部做好异常捕获返回一个对模型友好的错误描述而不是让异常直接抛出来。比如tool def query_database(sql: str) - str: 执行数据库查询。 Args: sql: SQL 查询语句 try: # 实际数据库查询逻辑 result db.execute(sql) return str(result) except ConnectionError: return 数据库连接失败请稍后重试 except SyntaxError as e: return fSQL 语法错误{e}请检查语句格式这样模型能根据具体的错误类型决定是重试、换工具、还是告知用户。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。你明明绑定了工具但模型就是不用直接用自己的知识回答。原因通常有三个第一工具描述不够清晰模型没理解这个工具是干什么的第二系统提示词里没有引导模型使用工具第三模型本身的能力限制一些小模型对工具调用的支持不好。解决办法把工具描述写得具体一些包含使用场景和示例。比如不要写“查询天气”而是写“查询指定城市的实时天气情况。当用户询问天气、气温、是否下雨等问题时使用此工具”。另外可以在系统提示词里加一句“你可以使用提供的工具来获取实时信息不要依赖你的训练数据来回答需要实时数据的问题”。5.2 工具调用陷入死循环怎么排查死循环的典型表现是模型反复调用同一个工具每次都得到相似的结果但就是不生成最终回复。我遇到过几次原因各不相同。有一次是tool_call_id不匹配模型认为工具没有执行成功所以一直重试。排查方法是打印每次ToolMessage的tool_call_id和模型返回的tool_calls里的id逐一对比。另一次是工具返回的结果格式模型无法理解。我那个工具返回了一个嵌套很深的 JSON模型解析不了就反复调用试图获取更简单的格式。后来我把返回结果改成扁平化的字符串问题就解决了。还有一种情况是工具本身有 bug每次返回的都是错误信息模型看到错误就想重试。这种需要检查工具函数的日志确认实际执行结果。5.3 参数传递错误的典型模式模型传错参数的情况很常见我整理了一个速查表错误类型典型表现解决方案参数名拼写错误传了citty而不是city在工具描述里明确参数名或用 Pydantic 的 alias参数类型错误把数字传成了字符串在函数签名里标注类型Pydantic 会自动转换缺少必填参数只传了部分参数在描述里标注哪些是必填模型通常会补全参数值格式错误日期格式不对在参数描述里给出格式示例多传了不存在的参数传了工具不支持的参数Pydantic 默认会忽略额外参数但最好在描述里说明提示Pydantic 模型默认会忽略未定义的参数这看起来是好事但实际上会掩盖问题。我建议在工具定义时设置model_config {extra: forbid}这样模型传了多余参数时会直接报错方便排查。5.4 工具执行超时与并发控制当工具需要调用外部 API 时超时是必须考虑的问题。LangChain 的工具函数本身没有内置超时机制你需要在函数内部用requests的timeout参数或者asyncio.wait_for来控制。对于并发调用如果模型一次性返回了多个工具调用而你的工具函数是同步的LangChain 会用线程池并行执行。线程池的大小默认是 10如果工具调用数量超过这个值多出来的会排队。你可以通过环境变量LANGCHAIN_TOOL_THREAD_POOL_SIZE调整。但要注意不是所有工具都适合并行。如果多个工具操作同一个资源比如同时写入同一个文件并行会导致数据竞争。这种情况下你需要在工具函数内部加锁或者把工具标记为不可并行。6. 从 LangChain 到生产环境的几个关键考量6.1 工具调用的可观测性建设在开发环境跑通 Tool Call 只是第一步上生产环境之后你需要知道每次调用发生了什么。LangChain 提供了回调机制可以挂载BaseCallbackHandler来记录工具调用的开始、结束、错误等事件。我通常会把工具调用的日志写到结构化日志系统里包含以下字段会话 ID、工具名称、输入参数、输出结果、执行耗时、是否成功。这些数据对于排查问题和优化性能至关重要。比如你发现某个工具的平均耗时特别长就可以考虑加缓存或者优化实现。LangSmith 是 LangChain 官方提供的可观测性平台能自动追踪每次工具调用的完整链路。如果不想用外部服务也可以用 LangChain 的ConsoleCallbackHandler把日志打到控制台然后自己收集。6.2 安全边界工具权限的最小化原则工具调用本质上是在执行代码所以安全边界必须划清楚。我遵循的原则是每个工具只做一件事只访问它必须访问的资源。比如查询天气的工具不应该有写文件的权限计算表达式的工具不应该能访问网络。对于eval这类危险操作一定要限制可用的命名空间。我在前面的calculate工具里用了{__builtins__: {}}来禁用内置函数只允许math模块里的函数。即便如此eval仍然有风险生产环境建议用ast.literal_eval或者专门的表达式解析库。另一个安全考量是参数注入。如果工具函数直接把参数拼接到 SQL 语句或者 shell 命令里就可能被注入攻击。模型本身不会恶意注入但用户可能通过精心构造的输入诱导模型传入恶意参数。所以工具函数内部必须做参数校验和转义。6.3 性能优化减少不必要的工具调用每次工具调用都意味着一次额外的模型请求和一次外部函数执行延迟和成本都会增加。优化方向有两个一是让模型更准确地判断何时需要调用工具二是减少工具调用的轮次。对于第一个方向可以在系统提示词里明确告诉模型哪些问题不需要工具。比如“对于常识性问题直接回答不要调用工具”。对于第二个方向可以把多个相关操作合并成一个工具。比如不要分别提供“查询用户信息”和“查询用户订单”两个工具而是提供一个“查询用户完整信息”的工具一次返回所有需要的数据。还有一个技巧是缓存工具结果。如果同一个工具在短时间内被多次调用且参数相同可以直接返回缓存结果。LangChain 本身不提供工具级别的缓存但你可以用functools.lru_cache装饰工具函数或者用 Redis 做分布式缓存。6.4 多轮对话中的上下文管理在长对话中工具调用的历史会不断累积导致上下文越来越长。如果不加控制很快就会超出模型的上下文窗口限制。LangChain 提供了几种上下文管理策略一是截断最早的对话轮次二是对历史消息做摘要三是只保留最近 N 轮的工具调用记录。我通常的做法是保留最近 5 轮完整的工具调用记录更早的只保留最终回复去掉中间的ToolMessage。这样既能保持对话的连贯性又能控制上下文长度。具体实现可以用 LangChain 的trim_messages函数或者自己写一个过滤逻辑。注意去掉ToolMessage时要小心如果模型在后续对话中引用了之前的工具调用结果去掉之后模型会“失忆”。所以最好在系统提示词里告诉模型“只基于最近的对话内容回答不要引用更早的工具调用结果”。7. 我踩过的坑与实操心得7.1 工具描述里的“隐藏陷阱”工具描述看起来只是给模型看的说明文字但它直接影响模型的调用决策。我踩过最大的一个坑是在工具描述里用了否定句。比如写“此工具不适用于查询历史天气”结果模型反而更倾向于调用它来查历史天气。后来我改成“此工具仅用于查询实时天气历史天气请使用其他工具”问题就解决了。另一个坑是描述太长。我一开始把工具描述写得非常详细包含各种边界情况和示例结果模型在判断是否调用时反而犹豫不决。后来我把描述精简到两三句话把详细说明放到参数的描述里效果好了很多。7.2 参数默认值的处理Python 函数可以有默认参数但模型不知道默认值的存在。如果你定义def search(query: str, limit: int 10)模型可能只传query然后期望limit自动是 10。但实际上LangChain 生成的 JSON Schema 里limit不是必填项模型不传的话Pydantic 会用默认值填充。这个行为是符合预期的但前提是你在函数签名里正确设置了默认值。我遇到过一种情况模型传了limitNone而不是不传。这时候 Pydantic 不会用默认值而是把None赋给limit导致后续代码出错。解决办法是在参数描述里明确写“如果不指定默认为 10”或者在函数内部处理None的情况。7.3 工具返回值的格式化技巧工具返回给模型的内容格式很重要。我试过返回 JSON 字符串、返回纯文本、返回 Markdown 表格实测下来模型对纯文本的解析准确率最高。JSON 虽然结构清晰但模型有时候会“过度解读”把 JSON 里的字段当成新的指令。返回内容要尽量简洁只包含模型需要的信息。比如查询天气返回“北京晴25°C”就够了不需要返回湿度、风速、气压等一堆数据。信息太多会干扰模型的判断增加它生成错误回复的概率。如果工具返回的是列表最好在开头加上数量说明比如“找到 3 条结果...”。这样模型能快速了解返回内容的规模决定是直接展示给用户还是进一步处理。7.4 调试工具调用的实用方法调试 Tool Call 最有效的方法是打印完整的消息序列。每次模型调用和工具执行后把messages列表里的所有消息按顺序打印出来包括HumanMessage、AIMessage带tool_calls、ToolMessage。这样你能清楚地看到模型在每一步做了什么决策。另一个技巧是用llm_with_tools.invoke(messages)的返回值直接检查tool_calls字段。如果tool_calls是空列表说明模型决定不调用工具如果有内容检查name和args是否符合预期。我还习惯在工具函数内部加日志记录每次调用的参数和返回结果。这样当模型行为异常时可以快速判断是模型传错了参数还是工具本身有问题。7.5 从单工具到多工具的扩展经验刚开始用 Tool Call 时建议从单个工具开始跑通整个流程后再逐步增加。每增加一个工具都要重新测试模型是否能在正确的场景下选择正确的工具。我见过太多人一次性绑定了十几个工具结果模型经常选错排查起来非常痛苦。工具数量增加后考虑给工具分组。比如把“查询类”工具放在一组“操作类”工具放在另一组根据对话的上下文动态绑定不同的工具集。LangChain 支持在运行时动态选择工具你可以根据用户意图或者对话阶段来决定给模型看哪些工具。最后分享一个我常用的测试方法构造一组覆盖各种场景的测试用例包括“需要调用工具”“不需要调用工具”“需要调用多个工具”“工具调用会失败”等情况每次修改工具定义或提示词后跑一遍测试用例观察模型的行为变化。这个方法能帮你快速发现回归问题避免改了一个地方影响了另一个地方。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑