☰
最小Demo讲透LLM Function Calling与本地工具协作
2026/10/14 22:15:12 网站建设 项目流程

最近几次聊 Agent 相关面试,我发现一个高频现象:候选人能讲一堆概念,从 ReAct 到 Plan-and-Execute,从记忆模块到多智能体协作,但一被问“你实际写过让大模型调用本地函数的代码吗”,很多人就卡住了。

概念听上去很多,真正落地的最小闭环反而简单到让人意外。所以我想用最小 demo 讲明白 LLM 是如何和本地的 Tool 合作的,核心就一件事:模型不是直接执行代码,而是“做出一个调用的决定”,由你本地代码真正跑这个函数,再把结果喂回去。适合正在准备 AI Agent 面试的人,也适合刚接触 Agent 开发、想知道 function calling 到底怎么回事的同学。

这个 demo 尽量保持最小,但该讲清楚的东西一个都不会少:工具描述怎么写、模型返回什么结构、本地调度循环怎么处理、踩坑怎么排查。你看完可以直接在本地跑起来,然后拿它去应对面试里 90% 的追问。

1. 最小 Demo 的整体设计思路与选型

1.1 先想清楚:这个 Demo 到底在证明什么

很多人在面试时说“我做过 Agent”,但问细节就露馅。问题出在大家把 Agent 想得太大了,又是规划又是记忆又是多模型协同。真正核心的机制其实是一条很短的链路:把外部工具的描述交给 LLM,LLM 根据用户问题决定“该调用哪个工具、传什么参数”,返回一个结构化的调用请求,本地代码解析并执行真实函数,最后把执行结果放回对话里让模型继续回答。

这个最小 demo 要证明的就是这条链路能跑通,而不是要做一个生产级平台。所以我的设计原则只有三条:代码量少、依赖少、可观察。任何一步出问题,你都能通过打印日志看到消息来往,这才适合用来学习和面试展示。

1.2 技术选型:为什么选“工具描述 + JSON Schema”这条主路

现在主流的大模型接口基本都支持 function calling,也叫 tool calling。实现方式虽然各家有细节差异,但大体逻辑一致:你额外传一个工具列表,里面包含函数名、描述、参数结构,模型在生成回答时会判断是否需要调用工具,如果需要,就返回一个结构化的 JSON,而不是自然语言。

我推荐用这个方式而不是让模型“把函数名写在文本里然后你自己解析”,原因是可靠性。function calling 是模型在训练和指令对齐阶段专门强化过的能力,返回格式稳定得多。用正则去文本里找函数名,属于自己造轮子,面试时如果被问到为什么不这么做,你可以理直气壮说:让模型用结构化输出,解析成本低、容错率高,也更符合工程上的最小可行原则。

1.3 最小闭环该有哪些部分

一个完整的最小 demo 不需要数据库、不需要消息队列、不需要向量检索。只要四块:

  • 一个支持 function calling 的模型服务
  • 一份工具描述,用 JSON Schema 描述本地函数
  • 一个本地函数,比如查天气或算算术
  • 一个调度循环,负责把模型返回的调用意图转成实际执行,再回传结果

我把这四块看作一个“请求-响应闭环”:用户问题进来,模型说要调工具,本地代码去执行,执行结果回到模型,模型基于结果给出最终回答。整个环节里只有模型是不可替换的,其他三块都是你自己可控的代码,所以出了任何问题都好排查。

2. 核心原理拆解:LLM 与本地 Tool 的合作协议

2.1 Function Calling 的本质:一次“职权分离”的对话

要理解这个机制,最直观的类比是“你派了个实习生去干活”。实习生很聪明但不会操作你的内部系统,你给他一本说明书,说明书里写清楚了有什么工具、每个工具干什么、参数是什么格式。用户提需求后,实习生看一眼说明书,说“我需要用到查天气工具,参数是北京”,然后你接过这句话,自己去执行真正的查询,把结果写在一张纸条上递给他,他再继续回答用户。

这个过程中模型只负责“判断和表达意图”,真正执行工具的始终是你本地代码。这个边界极其重要,面试官特别喜欢顺着这个往下问:如果模型返回了一个不存在的工具怎么办?如果工具执行报错怎么办?答案都要围绕“你的调度器是最终的裁决者”来展开。

2.2 工具定义长什么样:给模型看的说明书

在调用模型时,你需要传一个 tools 参数,它是一组描述对象的数组。每个对象通常包含 type、function 两个字段,而 function 里面又包含 name、description、parameters,其中 parameters 是一个 JSON Schema。

这里最关键的其实是 description。模型不是靠读你的 Python 函数名来理解工具的,它只靠这段文字。比如你的函数叫 get_weather_data,模型根本不知道里面是什么逻辑,但如果你在 description 里写清楚“获取指定城市的当前天气,适合回答天气、温度、降雨相关问题”,模型就知道什么时候该用你了。

参数部分也一样。参数的 properties 里每个字段都要写清楚类型、含义,必要时加上 enum 枚举。模型会根据用户问题里的信息去填这些字段,比如用户说“北京今天冷不冷”,模型就有较大概率把 city 填成“北京”。

2.3 模型返回的“伪调用”与调度器该做的事

模型返回的消息里,如果它决定调用工具,通常会有一个 tool_calls 字段,每个元素包含一个 id、function.name 和 function.arguments。注意 arguments 是一个 JSON 字符串,不是对象,所以你要做一步 JSON 解析。

这个“伪调用”的意思就是模型并没有真正执行任何东西,它只是告诉你“我想调这个函数,参数是这个”。真正做事的是你调度器里的分派逻辑:根据 name 找到本地函数,把解析后的参数传进去,拿到真实的返回值。

拿到返回值后,不是直接拼成文本丢回模型。你需要构造一条 role 为 “tool” 的新消息,并且带上 tool_call_id 来对应刚才那一次“伪调用”,然后和原始消息列表一起再发给模型。模型看到工具返回的真实数据后,才会生成“北京当前气温是 12 度,体感偏凉”这种最终回答。

3. 从零写一个能跑的最小 Demo

3.1 准备工作:环境与模型服务

这个 demo 我建议用 Python,不需要任何 Agent 框架,只需要一个能发 HTTP 请求的库。模型服务方面,你本地如果跑了一个支持 function calling 的开源模型,直接连本地地址就行;如果用的商业模型 API,只需要改一下 base_url 和 api_key。

我这次用一个通用的兼容接口来演示,假设模型服务的地址是 http://your-llm-service/v1,你换成自己的实际地址就行。环境准备就三步:装 Python、装 requests 库、确认模型服务可用。

pip install requests

3.2 完整代码结构:调度循环

接下来是整个 demo 的核心代码。为了保持最小,我只写一个函数和一个循环,把容易出问题的异常处理也提前加上。

import json import requests BASE_URL = "http://your-llm-service/v1" MODEL = "your-model-id" API_KEY = "your-api-key" # 如果本地服务不需要鉴权,可以留空 def get_weather(city: str) -> str: """本地模拟真实天气查询。""" # 真实项目里这里会去请求一个天气服务,demo 直接模拟返回 fake_data = { "北京": {"temperature": 12, "condition": "多云", "humidity": 40}, "上海": {"temperature": 18, "condition": "小雨", "humidity": 75}, } data = fake_data.get(city, {"temperature": 20, "condition": "未知", "humidity": 50}) return json.dumps(data, ensure_ascii=False) TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的当前天气信息,适合回答温度、天气状况、是否适合出行等问题", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "需要查询天气的城市名称,例如北京" } }, "required": ["city"] } } } ] def run_agent(user_input: str, max_steps: int = 5): messages = [ {"role": "system", "content": "你是一个有用的助手,需要调用工具回答用户问题时,先调用工具,再根据工具结果回答。"}, {"role": "user", "content": user_input} ] for step in range(max_steps): print(f"\n===== Step {step + 1} =====") resp = requests.post( f"{BASE_URL}/chat/completions", headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" }, json={ "model": MODEL, "messages": messages, "tools": TOOLS, "tool_choice": "auto" } ) resp.raise_for_status() msg = resp.json()["choices"][0]["message"] messages.append(msg) if msg.get("tool_calls"): for tool_call in msg["tool_calls"]: name = tool_call["function"]["name"] args = json.loads(tool_call["function"]["arguments"]) print(f"[Tool Call] {name}({args})") if name == "get_weather": result = get_weather(city=args["city"]) else: result = json.dumps({"error": f"unknown tool: {name}"}, ensure_ascii=False) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result }) else: print(f"[Final Answer] {msg['content']}") return msg["content"] print("[Warn] 达到最大步骤数,退出循环。") return None if __name__ == "__main__": run_agent("北京今天天气怎么样?适合出门跑步吗?")

你可以看到,整个调度循环核心就两步:发消息给模型,检查返回里有没有 tool_calls;有就执行工具并追加 tool 消息,没有就走完收工。这一段代码已经是一个各方面都齐全的最小 Agent。

3.3 参数选择:为什么 tool_choice 要设成 auto

工具描述里我留了一个参数叫 tool_choice,它的取值一般是 auto、none 或者强制指定某个工具。auto 是最常用也最合理的设置,让模型自己判断什么时候该调工具、什么时候直接回答。如果你显式设成某个固定工具模型,那模型就被你锁死了,不适合大多数对话场景。

还有一个容易忽略的细节:我在 system prompt 里强调了“先调用工具,再根据工具结果回答”。这个提示词在某些模型上能明显提升调用率。不要以为把 tools 传进去就万事大吉,模型的调用倾向会受到系统提示词、参数描述清晰度等多方面影响,这些后面都会在排查部分展开。

3.4 运行效果与链路解读

运行这段代码,你会看到类似这样的日志输出:

===== Step 1 ===== [Tool Call] get_weather({'city': '北京'}) ===== Step 2 ===== [Final Answer] 北京当前气温 12 度,多云。如果出门跑步,体感偏凉,建议穿一件防风外套。

链路其实只发生了两次请求:第一次模型判断需要调工具,第二次模型拿到工具真实结果生成回答。所谓的 Agent 推理,在这份日志里清晰可见,你在面试时可以把这个日志截下来讲解,比空谈概念直观得多。

4. 调试与常见问题的排查实录

4.1 模型就是不调用工具,一直在闲聊

这是最常踩的坑。你发现模型压根不返回 tool_calls,而是直接回答“我无法查询天气”。先从三个方向排查:第一,确认模型服务本身支持 function calling,有些小参数模型或微调版本并不支持,这个问题最容易忽略;第二,检查工具描述是否拖泥带水,描述越清晰,模型越容易做出对应判断;第三,检查消息格式是否正确,尤其是 models 接口版本,很多诡异问题都出在 API 参数名不匹配上。

我自己的经验是,如果你迫不得已要用一个比较弱的模型,可以把 system prompt 改得像“命令”一样,比如“当用户询问任何需要实时数据的问题时,你必须调用 get_weather 工具后再回答,禁止直接猜测”。这种情况下调用率会明显提高。

4.2 参数传不进去,报格式错误

模型返回的 function.arguments 是字符串而不是字典,这是我见过的第二个高频坑。如果你忘记 json.loads,直接当字典用,肯定会报错。更麻烦的是,有些模型偶尔会在 JSON 里加一点多余的说明,或者用单引号、尾逗号,导致 json.loads 直接抛异常。

我建议写一个容错解析函数。先用正则把参数里的代码块标记去掉,再尝试 json.loads;如果失败,可以尝试把单引号替换成双引号后再解析。不要嫌麻烦,真实项目里这种脏数据很常见。另外要多传一个参数校验逻辑,即使解析成功了,也要检查 required 字段是否都有值,防止把 None 传给本地函数。

def safe_json_loads(text): try: return json.loads(text) except json.JSONDecodeError: cleaned = text.strip().strip("`").lstrip("json") return json.loads(cleaned)

4.3 模型拿到了工具结果还是答得乱七八糟

可能你已经看到日志里 Step 1 的 tool call 正常,Step 2 的模型回答却和工具结果对不上。这个问题大多是因为工具返回的 content 太长或者结构太复杂,超出了模型的注意力范围,或者中间夹杂了大量无关字段。

解决思路是“先瘦身再返回”。真实项目里工具返回结果如果是几万字的日志,你不能原样丢给模型,应该只提取关键字段、做摘要,或者预先截断。不要把模型当成什么都能处理的数据库。

4.4 工具循环调用死循环

个别时候模型会连续调用同一个工具而不收敛,比如先查天气、再查天气、第三次还查天气。调度循环如果没设上限,请求就会一直发下去。我代码里加了一个 max_steps 参数,就是为了防止这个问题。生产环境里更要多加几层保险:单次工具调用超时、总步数上限、工具被连续调用多次且结果不变时强制中断。

这种死循环在两层工具协作时更容易出现,A 工具结果喂给 B 工具,B 又调 A。调试时一定要打印每条消息的角色和 tool_call_id,否则你看不出模型到底在基于哪条结果做判断。

5. 面试官最可能追问的 6 个问题

下面这几个问题基本覆盖了 function calling 面试的高频考点,建议你对着这个最小 demo 先自己模拟回答一遍。

5.1 意图识别与工具选择的边界

面试官会问:如果用户问的问题和你的工具完全不相关,模型会怎么办?答案取决于 tool_choice 和系统提示词。大多数情况模型会选择不调用工具,直接回答。但边界场景是模糊问题,比如“北京天气怎么样和上海相比呢”,模型可能只调用一次查询,也可能调用两次,这取决于模型对问题的拆解能力。

实际开发里,你需要在系统提示词里约定拆解策略,或者用额外的规划层控制工具调用次数。这个最小 demo 虽然不体现这一点,但你要能讲清楚它是扩展方向。

5.2 并行调用:能一次调多个工具吗

主流模型接口支持在一次返回里带多个 tool_calls,技术上你只要遍历数组执行即可。但要注意:并行调用的工具之间如果有依赖顺序,就不能盲目并行,比如“先查用户所在城市,再查该城市天气”,这两步有依赖关系,必须拆成两轮。最小 demo 的循环天然支持这种迭代执行,因为每一步都会重新把历史消息喂给模型。

5.3 工具报错要不要返回给模型

我建议大部分情况下返回错误字符串给模型,而不是直接中断。例如 get_weather 遇到一个城市不在数据表里,你返回“city not found”,模型就能基于这个反馈向用户解释“暂时没有该城市的数据”,或者换个方式问。这样用户体验更自然,也能缓解模型胡乱编造结果的问题。

但要注意区分两类错误:工具本身可处理的业务错误可以返回模型,而基础设施错误比如鉴权失败、网络超时,就不应该给模型,直接自己捕获并走降级逻辑。

5.4 上下文管理与安全边界

每多一轮工具调用,历史消息就会多两条。极端情况下,工具结果很大,很容易把上下文塞满。所以生产级 Agent 必须做上下文裁剪,比如只保留最近的几轮消息,或者对工具结果做摘要。安全边界则更关键,尤其是你暴露给模型的是危险工具时,权限校验一定要放在本地而不是依赖模型自觉。

5.5 流式输出时如何处理函数调用

流式输出是另一个容易被追问的点。聊天类应用为了打字机效果,会把模型输出流式推给用户,但工具调用场景下,你面对的不是最终回答而是结构化的 JSON,流式拼接超时和稳定性都会变差。我的建议是分场景处理:功能底层用非流式保证稳定,最终生成回答时再用流式输出给用户。面试时能讲出这个取舍,会比直接说“我用流式”更有说服力。

5.6 怎么评测这个链路

最后,面试官可能会问你如何判断链路是好的。我习惯准备一小组测试用例,大概二十条不同类型的问题,覆盖“需要调工具”“不需要调工具”“参数缺失需要反问”三类。然后用三个指标评估:工具调用准确率、参数传递准确率、最终任务完成率。这个评估过程本身就能体现你是有工程意识的,而不是只会调接口。

6. 如何把 Demo 讲出项目深度

6.1 讲解顺序:从现象到原理再到取舍

如果你面试时要展示这个 demo,不要一上来就贴代码。先抛现象:“模型本身不会执行函数,但它能决定什么时候该执行。”然后演示一次调用日志,让对方看到两次请求、一次 tool call、一次 final answer。再解释工具描述、参数结构、调度循环,最后主动讲你自己的取舍——为什么没用框架、为什么工具结果要先瘦身、为什么 max_steps 要设上限。

这样讲,面试官会明显感觉到你是真的亲手调过,而不是背了八股文。

6.2 从最小 Demo 到生产,还需要哪些补强

你还可以主动列出这个 demo 的局限,以及补齐方案。第一,工具数量多了以后,描述可能互相覆盖,需要在系统提示词里做分组或者让模型先做工具检索。第二,一次请求如果失败,要不要重试、重试几次,要有一套合理策略。第三,可观测性非常重要,真实生产一定会有完整的日志面板,能回放每一轮对话的工具调用记录,否则线上问题根本没法排查。第四,工具执行要做超时和并发控制,不能让一个慢工具卡住整个 Agent。

能把这个清单讲清楚,说明你不只是一个 demo 编写者,而是一个考虑过生产落地的人。

6.3 我踩过的坑与个人体会

最后一个个人经验:给工具起名字和写描述,值得多花时间。我第一次写工具描述的时候写得很啰嗦,结果模型经常在不需要的时候也去调用,后来把描述压缩成一句话加几个典型场景,准确率反而上来了。工具名称也尽量用机器容易理解的动词加名词结构,比如 query_weather_detail,而不是 getData,模型对自然语言风格的函数名响应更好。

还有一次,我在一个模型上调试了很久就是不调用工具,排查到最后发现问题出在消息顺序上——我把 system prompt 放到了 user 消息后面,一些实现会因此忽略系统提示。这个坑让我养成了习惯:凡是调用异常,先把请求体完整打印出来看一遍,很多时候问题一眼就能看到,比在引擎里猜来猜去高效得多。

这个最小 demo 大概一百行不到,但掌握了它,你等于把 LLM 与本地工具协作这件事的主干摸了一遍。后续再去看复杂的 Agent 框架时,你会发现框架解决的大多是稳定、扩展、可观测的问题,核心的“模型决定、本地执行、结果回传”循环,就是这个最小 demo 里的样子。带去面试时,与其报一堆华丽概念,不如亲手跑一遍这个循环并讲清楚每一步,更容易让人觉得靠谱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询