最近几次聊 Agent 相关面试我发现一个高频现象候选人能讲一堆概念从 ReAct 到 Plan-and-Execute从记忆模块到多智能体协作但一被问“你实际写过让大模型调用本地函数的代码吗”很多人就卡住了。概念听上去很多真正落地的最小闭环反而简单到让人意外。所以我想用最小 demo 讲明白 LLM 是如何和本地的 Tool 合作的核心就一件事模型不是直接执行代码而是“做出一个调用的决定”由你本地代码真正跑这个函数再把结果喂回去。适合正在准备 AI Agent 面试的人也适合刚接触 Agent 开发、想知道 function calling 到底怎么回事的同学。这个 demo 尽量保持最小但该讲清楚的东西一个都不会少工具描述怎么写、模型返回什么结构、本地调度循环怎么处理、踩坑怎么排查。你看完可以直接在本地跑起来然后拿它去应对面试里 90% 的追问。1. 最小 Demo 的整体设计思路与选型1.1 先想清楚这个 Demo 到底在证明什么很多人在面试时说“我做过 Agent”但问细节就露馅。问题出在大家把 Agent 想得太大了又是规划又是记忆又是多模型协同。真正核心的机制其实是一条很短的链路把外部工具的描述交给 LLMLLM 根据用户问题决定“该调用哪个工具、传什么参数”返回一个结构化的调用请求本地代码解析并执行真实函数最后把执行结果放回对话里让模型继续回答。这个最小 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. 从零写一个能跑的最小 Demo3.1 准备工作环境与模型服务这个 demo 我建议用 Python不需要任何 Agent 框架只需要一个能发 HTTP 请求的库。模型服务方面你本地如果跑了一个支持 function calling 的开源模型直接连本地地址就行如果用的商业模型 API只需要改一下 base_url 和 api_key。我这次用一个通用的兼容接口来演示假设模型服务的地址是 http://your-llm-service/v1你换成自己的实际地址就行。环境准备就三步装 Python、装 requests 库、确认模型服务可用。pip install requests3.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_asciiFalse) 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: fBearer {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(cityargs[city]) else: result json.dumps({error: funknown tool: {name}}, ensure_asciiFalse) 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 里的样子。带去面试时与其报一堆华丽概念不如亲手跑一遍这个循环并讲清楚每一步更容易让人觉得靠谱。