恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

大模型工具调用实战:从JSON解析到Function Calling的三种实现

  • 首页
  • 资讯中心
  • /
  • 大模型工具调用实战:从JSON解析到Function Calling的三种实现

相关资讯

构建可复用Agent技能系统:从架构设计到调度实践 2026/10/12 4:28:59
大数据可视化技术原理与性能优化实战指南 2026/10/12 4:28:59
mlpack 嵌入式交叉编译实战:从 CMake 模板到目标硬件部署 2026/10/12 4:28:59

最新资讯

Agent开发前置基础知识点全总结:零基础入门必读
基于V2G的电动汽车实时调度策略Matlab仿真实现
P1220 关路灯【洛谷算法习题】
Hive 源码导读(三):都是 SELECT,为什么有的查询不需要 YARN?
大厂年薪600万抢AI博士?别焦虑!3个方法让你在AI时代不落伍
WinForms左导航右内容最佳实践

今日推荐

Debian新手入门:从部署到日常操作的完整指南
MongoDB复制集扩缩容实战:从rs.add到选主事故复盘
条形码目标检测数据集实战:从YOLOv8训练到部署

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

大模型工具调用实战:从JSON解析到Function Calling的三种实现

发布时间:2026/10/12 4:28:59
大模型工具调用实战:从JSON解析到Function Calling的三种实现 开场直接说“上个月面了一家做AI应用的创业公司技术面环节面试官抛了一个很开放的问题假设我们接入了大模型现在需要让它能调用我们自己的函数比如查库存、下单你要怎么实现我说最简单就是让模型输出一个固定格式的JSON然后我来解析分发。他点点头又问如果模型输出不稳定呢如果参数传错了呢如果你要做成生产系统呢于是我从方案一讲到方案三当场手写了一套最小可用实现。今天把这三个方案整理成文给正在准备LLM应用开发面试的朋友做个参考。”这是很好的开头。注意“上个月面了一家”是虚构但没有真实人名/公司可以。合规。然后正文。要符合H2编号。还需要不需要主标题不需要直接从##.我们将全文完成。开始输出。 上个月面了一家做AI应用的创业公司技术面环节面试官抛了一个很开放的问题假设我们接了大模型现在需要让它能调用我们自己业务里的函数比如查库存、下单要怎么做我当时的回答没有直接背八股而是明确说“我通常按从简到繁演进可以给你三个方案。”然后从最朴素的提示词解析讲到原生Function Calling最后手写了一套最小可用的调用分发骨架。他追问了几个边界情况后明显对这套思路比较满意。今天把这三种方案整理成文给同样在准备LLM应用开发面试的朋友做个参考。1. 这个题到底在考什么1.1 为什么面试官要手写 Tool Use现在很多LLM应用都绕不开工具调用聊天机器要查天气、订餐助手要下单、运维机器人要查机器状态本质上都是同一个问题怎么让模型“决定”调用业务函数并且把用户的话转成函数的入参。表面上看这件事可以交给各类成熟的Agent框架但手写一遍的价值在于它能看出你对模型输入输出、解析容错、函数映射关系是否真正理解。面试官不指望你写一个全平台的Agent他想看到的是一个“即使没有框架也能从零搭起来”的工程直觉。另外这个题目能顺便考察你对底层协议的理解。比如你有没有想过模型输出到底是怎么变成一次函数调用的函数返回值又是怎么重新拼进对话的很多人用过OpenAI的function calling但面试时说不清它的数据流这就是基本功不扎实。1.2 手写方案的整体设计框架无论哪种方案核心都可以拆成四层工具注册表把业务函数和它的元信息函数名、参数Schema、函数体统一登记。模型交互层让模型理解有哪些工具可用并输出“要不要调、调哪个、参数是什么”。解析与分发层从模型输出中提取工具调用意图映射到注册表中的函数。结果回填层把函数执行结果作为新的上下文给模型让它继续生成最终回答。我在手写时总是先把这四层拆出来再根据场景决定每一层做到什么程度。方案一、方案二、方案三的区别本质上就是“模型交互层”和“解析分发层”的成熟度不同。2. 方案一纯Prompt JSON解析最原始但能跑2.1 工具注册表与函数分发方案一完全不依赖模型的特殊能力只依赖一个前提模型看得懂提示词能输出文本。所以第一步是定义工具注册表把“系统有什么工具”这件事集中管理。我用一个字典来表示TOOL_MAP { get_weather: { fn: get_weather, description: 查询城市天气, params: { city: {type: string, required: True, desc: 城市名}, unit: {type: string, required: False, desc: 摄氏度或华氏度} } }, calc: { fn: calc, description: 简单四则运算, params: { expr: {type: string, required: True, desc: 表达式如 12} } } }函数分发很简单就是一个switch映射def dispatch(tool_name: str, args: dict): tool TOOL_MAP[tool_name] return tool[fn](**args)这里的重点是注册表同时承担了“给模型看的功能说明”和“给代码跑的映射”。后面方案二、三都会在这个表上扩充字段只是展示方式不同。2.2 Prompt 构造与输出解析方案一的模型交互层是靠一大段提示词把工具清单塞给模型的。我会把注册表里的description和params格式化成自然语言你是一个智能助手。当用户需要查询天气时输出一个JSON格式如下 {tool: get_weather, args: {city: 杭州, unit: celsius}} 可调用工具列表 - get_weather查询天气。参数city(string,必填)unit(string,选填) - calc简单四则运算。参数expr(string,必填) 规则 1. 只有确实需要调用工具时才输出JSON。 2. JSON放在代码块json中。 3. 不要输出多余解释。用户输入进来以后我把系统提示词和用户消息拼在一起发给模型。返回内容出来后用正则去提取方括号里的JSONimport re, json def extract_tool_call(text: str): pattern rjson\s*(\{.*?\})\s* match re.search(pattern, text, re.DOTALL) if not match: # 容错直接找最大的{...} match re.search(r(\{.*\}), text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: return None return None拿到{tool: get_weather, args: {...}}后先校验工具存在再校验args里的必填参数然后分发执行。执行结果拼成一段“工具结果”消息再次发给模型tool_result dispatch(tool_name, args) messages.append({role: system, content: f工具返回结果{tool_result}}) final_answer call_model(messages)这就是一个能跑的闭环模型决定调用工具代码执行结果回填模型基于结果生成最终回复。2.3 方案一的实测效果和明显短板这种方案胜在模型无关任何支持自然语言的模型都能用只需要调Prompt。我在实际测试中遇到的最大问题是“模型不是每次都输出干净JSON”。有时它会在JSON前后加一句话有时会用单引号代替双引号有时布尔值变成小写还有时候多一个逗号。你不得不写一堆正则和修复逻辑去兜底。另一个坑是注入工具清单会挤占上下文窗口。工具数量一多Prompt就会很长而且每轮对话都要重复发送浪费token。更关键的是模型很可能“理解错”工具参数。比如你定义城市必须是中文名但它输出英文拼音或者把“温度单位”写成unit: 摄氏度而不是celsius。面试官如果让你手写这个方案千万别把正则写得过于复杂。你要展示的是“我知道它简单但有上限所以后续需要演进”而不是试图用正则修一切。3. 方案二用结构化输出约束模型稳定一个量级3.1 在请求参数上做文章方案二的思路是既然解析JSON这么痛苦那我就让模型接口在生成时就保证输出是合法JSON。很多大模型服务商都提供了JSON模式或者叫结构化输出比如在请求参数里加一个response_format{type: json_object}。它的原理是在解码阶段约束模型只能按JSON语法生成token从根源上避免乱加文字、断在中间、多逗号这类问题。此时我需要把工具清单和调用格式都塞到系统提示词里但模型必须输出JSON对象。代码骨架变成response model.chat( modelxxx, messagesmessages, response_format{type: json_object} ) content response.choices[0].message.content tool_call json.loads(content) # 这里基本不会失败相比方案一正则那套基本可以扔掉了。如果服务商支持定义输出JSON的Schema还能进一步限定字段结构比如要求{tool: str, args: object}更严的还能限定args里的每个参数类型。3.2 参数Schema设计经验既然走到结构化这层就值得把参数Schema认真设计一遍。我的习惯是给每个工具定义一份类似JSON Schema的结构{ type: object, properties: { city: {type: string, enum: [杭州, 上海, 北京]}, unit: {type: string, enum: [celsius, fahrenheit]} }, required: [city] }这里有两个经验能用枚举表达的字段务必用枚举。比如国家代码、币种、状态枚举能让模型在生成时就限定合法值大大降低后端校验压力。不要设计超过5个参数的工具。参数越多模型产出错误调用的概率越大。如果业务函数需要很多参数宁可拆成多个小工具或者做一个“泛化工具”让模型传一个完整JSON后台再二次解析。我在方案二里还会加一层“候选工具过滤”。比如用户只提到了“天气”相关词我就只把天气相关的工具塞进系统提示词而不是把几十个工具全塞进去这样既省token也减少模型选错工具的概率。3.3 重试与兜底手写容错结构化输出能解决格式问题但解决不了语义问题。模型可能输出一个JSON但tool名字压根不存在或者参数缺少必填项。这时我会写一个三明治校验逻辑def safe_invoke_tool(tool_call, history): # 第一层JSON结构校验 if not isinstance(tool_call, dict): return None, 模型输出不是对象 # 第二层工具名校验 tool_name tool_call.get(tool) if tool_name not in TOOL_MAP: return None, f未知工具:{tool_name} # 第三层参数校验 args tool_call.get(args) or {} missing required_params(TOOL_MAP[tool_name][params]) - args.keys() if missing: return None, f缺少参数:{missing} try: return dispatch(tool_name, args), None except Exception as e: return None, f执行异常:{e}如果返回了错误信息就把它拼成一条“工具调用错误”的system消息重新发给模型让模型自行修正或回答。这个“校验失败 - 回传错误 - 模型自纠”的循环是比普通重试更好用的兜底策略。面试时方案二可以重点讲“我如何把不稳定降到最低”但也要坦诚说它仍依赖模型对参数语义的把握当工具数量继续膨胀时还是有局限性。4. 方案三原生Function Calling生产级解法4.1 tools 参数与 tool_calls 的握手流程方案三是现在生产环境里最常用的做法很多模型服务商推出了原生的函数调用能力也就是Function Calling。它的本质是不需要让模型用文本描述“我要调什么”而是由接口层用结构化字段直接表达。流程分三步我们在请求里传入tools参数里面是每个函数的名称、描述、参数Schema。模型理解用户需求后如果觉得需要调用工具返回的响应里会带tool_calls字段而不是把调用意图藏在文本里。我们拿到tool_calls执行函数再把执行结果以tool角色消息发回去模型基于结果生成最终回复。需要注意的是模型返回的tool_calls可能不止一个这意味着它可能在一个回复里并行请求调用多个工具。我在手写时如果忽略这种情况很可能丢掉部分调用结果。4.2 完整多轮调用代码骨架这里给出我当时写在白板上的一套精简实现。工具注册表沿用前面的TOOL_MAP但要把params整理成 tools 需要的格式def build_tools_spec(): tools [] for name, meta in TOOL_MAP.items(): tools.append({ type: function, function: { name: name, description: meta[description], parameters: meta[schema] # 从注册表里读取预定义的JSON Schema } }) return tools一次完整的工具调用循环def run_with_tools(user_input): history [{role: user, content: user_input}] while True: resp model.chat( modelxxx, messageshistory, toolsbuild_tools_spec() ) msg resp.choices[0].message if not msg.tool_calls: return msg.content # 没有工具调用直接返回最终回答 # 记录assistant消息其中包含tool_calls history.append(msg.model_dump()) for tool_call in msg.tool_calls: fn_name tool_call.function.name args_str tool_call.function.arguments args json.loads(args_str) result dispatch(fn_name, args) # 把工具结果作为tool角色消息追加 history.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) # while循环继续模型拿到工具结果后会生成最终答复或继续调用这段代码里有几个关键点history.append(msg.model_dump())是为了保留模型刚才的tool_calls否则模型后续生成会缺失上下文。每个工具结果必须带tool_call_id它把“这一次调用”和“对应的结果”关联起来。用while True是因为模型可能连续调用多个工具比如先查天气、再根据天气推荐穿衣那就需要两轮提示。4.3 并发调用与中间结果的取舍原生Function Calling的一个天然优势是支持并发。模型返回多个tool_calls时如果这些工具之间没有依赖理论上可以并发执行。手写代码可以这样from concurrent.futures import ThreadPoolExecutor def invoke_parallel(tool_calls): with ThreadPoolExecutor(max_workers5) as pool: futures [] for tc in tool_calls: futures.append(pool.submit(dispatch, tc.function.name, json.loads(tc.function.arguments))) return [f.result() for f in futures]并发能显著降低一次Agent任务的端到端延迟。比如用户同时问“帮我查一下杭州的天气和明天的航班”两个工具互相独立并发可以并行完成。但并发也会带来一个问题如果工具之间有依赖比如“查天气”的结果要作为“推荐穿搭”的输入那就不能简单并发必须分步执行。这种场景需要有向无环图的执行编排对手写题来说超纲了。面试时你只要能讲清楚“哪些可以并发哪些必须串行判断标准是数据依赖”就已经比很多人强。5. 三种方案对比与演进路径5.1 参数对比表维度方案一纯PromptJSON解析方案二结构化输出约束方案三原生Function Calling模型兼容性任意NLP模型都行需要服务商支持JSON模式需要服务商支持Function Calling输出稳定性低正则补救有限中高格式稳定语义仍需校验高结构化字段天然稳定上下文消耗高工具清单全靠Prompt发送中高仍要写描述较低tools参数单独承载多工具支持需要特殊指令可以但解析靠手工原生支持一次返回多个tool_calls工程复杂度低中中高典型场景快速Demo、模型无FC能力企业内部可靠Agent生产级复杂任务这张表也是我给面试官展示的核心。它证明我不是只会写一个方案而是知道每种方案为何存在。5.2 面试的加分点路由表设计三种方案其实都依赖同一个东西工具注册表。如果面试官让我“再优化一下”我很可能会把注册表扩展成一张路由表让函数分发更加健壮class ToolRouter: def __init__(self): self._tools {} def register(self, name, schema, handler): self._tools[name] {schema: schema, handler: handler} def call(self, name, args): if name not in self._tools: raise KeyError(ftool not found: {name}) # 可以在这里先按schema校验参数 return self._tools[name][handler](**args) def list_schemas(self): # 生成给模型用的schema列表 return ...有了路由表后面切换方案时只需要改变“如何把schema传给模型”和“如何解析模型输出”这两层函数执行层完全不动。这是架构上的解耦也是面试官愿意看到的东西。6. 面试现场我是怎么回答的6.1 典型追问与应对思路面试官在我讲完方案三之后追问了几个问题这里整理出来我觉得很值得反复琢磨问题一如果模型输出的tool name存在但参数值明显不合理怎么办比如城市名是“abcde”。我的回答是在后端校验阶段加入语义合法性校验比如城市名必须命中一个内置城市列表否则返回错误信息让模型自纠。不要把轴全压在模型身上能用枚举、规则卡住的就卡死。问题二如果工具执行很慢比如查询用了10秒模型中间会不会超时我说需要把工具执行改成异步任务先返回“任务已提交”给模型之后通过轮询或事件通知更新结果。即时调用适合在演示里生产系统应当有任务状态机。问题三如果两个工具都需要写数据库怎么保证一致性我说工具层不应该直接操作数据库而应该通过领域服务接口事务边界在服务层控制。Agent工具只是接口的“翻译”。这些问题没有标准答案但一定要体现出“我理解生产系统的边界”而不是只会调接口。6.2 手写时最容易翻车的三个细节第一个细节是忘记把assistant消息追加回去。很多人在调用完工具后只把工具结果发给模型忽略最初那条带tool_calls的assistant消息导致模型上下文不完整。第二个细节是参数校验只做if args:的简单判断没有真正识别必填参数和类型。第三个细节是拿到工具结果后直接把它插到历史末尾却忘了在结果前补一句“这是工具返回的结果”导致模型无法建立调用与结果之间的关联。原生Function Calling里tool_call_id解决了这个关联但手写解析时你必须用文字把它讲清楚。另外手写时不要试图把代码写全。面试更看重边界条件和设计思路你可以在关键位置写注释然后口述“这里要判空”“这里要并发控制”效果往往比闷头写完更好。我当时在写第三个方案时故意在循环数限制那里留白然后告诉面试官“这里我会加一个最大迭代次数比如5次防止Agent死循环。”他眼神明显亮了一下。结尾的个人体会跑了这么多Agent项目我的体会是手写Tool Use最重要的不是把某个框架的函数调明白而是理解“模型输出如何变成可靠的程序行为”这条链路。方案一是最原始的思维启蒙它让我明白解析有多痛方案二让我学会给模型加约束而不是事后补救方案三则让我懂得原生能力永远比自己造轮子更稳但前提是你清楚后台发生了什么。如果你最近也在准备类似的面试建议你找一个真实场景比如“查天气”或“算账”把这三个方案各实现一遍。别只看文章亲手敲一遍尤其是那个while True的循环真跑通一次你对Agent的认知会扎实很多。面试官要的从来不是背诵而是你面对未知边界时能给出几层思考。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号