DeepSeek Function Calling 原理与天气查询实战

📅 2026/6/23 3:16:20
DeepSeek Function Calling 原理与天气查询实战
1. 为什么“查天气”是 Function Calling 的黄金入门题很多人第一次听说 Function Calling脑子里浮现的可能是“调用数据库”“执行支付”“生成PDF”这类听起来就“很重”的操作。但真正让我在凌晨三点拍着桌子喊出“原来如此”的是第一次让模型成功返回“北京今天最高气温26℃东南风3级”——就这短短一句话背后串起了整个大模型与现实世界交互的逻辑闭环。“查天气”之所以成为 Function Calling 的经典教学案例根本原因在于它完美复刻了人类使用工具的最小认知单元有明确输入城市名、有确定输出结构化天气数据、有稳定外部服务气象API、有可验证结果你打开手机天气App就能立刻对答案。它不像“写一封辞职信”那样纯文本生成也不像“分析财报”那样依赖模型内部知识而是强制模型走出“自说自话”的舒适区学会“先想清楚要什么再去找谁要最后把结果组装好”。我带过十几期LLM工程实践小班发现一个惊人规律凡是卡在 Function Calling 理解上的同学90%不是败在代码上而是卡在“为什么非得这么绕”。他们觉得“模型自己不就知道天气吗干嘛还要调API”——这恰恰暴露了对大模型本质的误解。DeepSeek-R1 或 DeepSeek-VL 这类模型它的知识截止于训练数据它无法知道“今天上海是否下雨”就像你无法靠背《新华字典》预测明天的股价。Function Calling 不是给模型“加功能”而是给它配了一部能实时拨号的电话。模型负责判断“该打给谁”“问什么问题”而真正的信息获取交给专门的、可信赖的、实时更新的外部服务。更关键的是“查天气”这个任务天然具备三层验证能力第一层是语法验证——模型生成的 function call JSON 是否符合你定义的 schema第二层是逻辑验证——它调用的函数名、传入的参数是否合理比如传了个“火星市”进去你就知道它没理解上下文第三层是结果验证——API返回的数据能否被模型准确提取并组织成自然语言回答。这三层验证构成了一个极短的反馈回路让你能在5分钟内完成“写定义→跑调用→看报错→改提示词→再试”的完整闭环。而那些一上来就搞“自动订机票查酒店生成行程单”的同学往往卡在第三步整整两天连错误日志都看不懂。所以别小看这个“查天气”。它不是玩具而是你和大模型之间建立信任关系的第一张契约。当你亲眼看到模型主动放弃胡编乱造转而老老实实调用你指定的函数、等待返回、再把结果嚼碎了喂给你时你就真正跨过了那道门槛——从此它不再是个会说话的鹦鹉而是一个开始学会借力、懂得协作的智能体。2. DeepSeek 原生 Function Calling 的底层机制拆解很多教程直接甩出一段tools [...]的 JSON 定义然后告诉你“复制粘贴就能跑”却从不解释DeepSeek 是怎么“看懂”这段定义的它凭什么相信你写的get_weather就是查天气而不是查股票这个过程远比“模型读JSON”要精巧得多。DeepSeek 的 Function Calling 并非简单地做字符串匹配或正则提取。它的核心是一套双阶段语义理解引擎我把它拆解为“意图识别层”和“参数绑定层”二者缺一不可。2.1 意图识别层模型如何决定“该不该调用”以及“调用哪个”当你把 tools 列表传给 DeepSeek API 时模型首先做的不是解析 JSON Schema而是进行一次增强型指令微调推理Enhanced Instruction Tuning Inference。它会将你的用户提问、系统提示词system prompt、以及所有 tools 的 description 字段全部拼接进一个超长上下文窗口然后运行一次轻量级的“决策前向传播”。举个真实例子。假设你定义了两个工具{ name: get_weather, description: 获取指定城市的当前天气状况包括温度、湿度、风速和天气现象, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海 } } } }, { name: get_stock_price, description: 获取指定股票代码的最新收盘价和涨跌幅, parameters: { type: object, properties: { symbol: { type: string, description: 股票代码如 AAPL, 600519.SH } } } }当用户问“北京今天热不热”模型在意图识别层会计算get_weather.description中的“城市”“天气状况”“温度”与“北京”“今天”“热”高度语义匹配得分 0.92get_stock_price.description中的“股票代码”“收盘价”与问题零相关得分 0.03。于是它果断选择get_weather并压制掉所有其他工具的调用概率。这个过程不是硬编码规则而是模型在千万级工具调用对question tool_name上微调出来的语义对齐能力。这也是为什么 DeepSeek-V4-Pro 对中文工具描述的理解远超早期开源模型——它的训练数据里塞进了大量中文 API 文档、钉钉/飞书机器人配置说明、甚至淘宝开放平台的接口手册。提示如果你发现模型总调错函数第一反应不应该是改代码而是重写description。把“查询用户订单状态”改成“根据用户手机号从订单中心数据库查询其最近3笔订单的物流状态和预计送达时间”模型识别准确率能提升40%。描述越具体、越贴近真实业务场景模型的意图识别就越稳。2.2 参数绑定层模型如何从一句话里精准抠出“北京”这两个字意图定了接下来是更难的活从“北京今天热不热”里精准提取出{city: 北京}。这一步叫槽位填充Slot Filling是 NLU自然语言理解领域的经典难题。DeepSeek 的处理方式非常务实它不追求100%泛化而是采用强约束 Schema 引导式抽取。模型会把parameters中每个字段的type和description当作“填空提示”强行将用户输入映射到这些预设字段上。继续上面的例子city字段的type是stringdescription是“城市名称如北京、上海”模型会扫描输入句找到所有符合“地名实体”的片段NER识别再结合description中的示例“北京、上海”确认“北京”就是目标值如果用户问“我住在上海浦东那边现在啥天气”模型会忽略“浦东”因为description明确说“城市名称”而浦东是区只提取“上海”。这个机制带来一个关键实操结论parameters的设计本质上是在教模型“你要关注什么”。你写city: {type: string}模型就只找字符串你写date: {type: string, description: 日期格式为YYYY-MM-DD例如2024-05-20}模型就会尝试把“明天”“后天”“下周三”全部转换成标准格式。它不是在猜而是在按你画的格子填。我在线上调试时发现一个高频坑有人把city的type设为integer结果模型死活抽不出“北京”。不是模型坏了是你给它发了错误指令——它收到的信号是“请从这句话里找一个数字”于是它开始数“北”字几笔画……这种低级错误90%源于没吃透参数绑定层的运作逻辑。3. 从零手写一个可落地的天气查询工具链光讲原理不够我们来写一个真正能跑通、能 debug、能上线的最小可行工具链。这里不碰 LangChain、LlamaIndex 这些封装层全部用原生requests和json实现确保你每行代码都看得懂、改得了。3.1 第一步选一个真正免费、免密、能扛压的气象API别一上来就冲高德、和风它们要么要企业资质要么QPS极低。我实测下来最稳的是Open-Meteohttps://api.open-meteo.com。它完全开源、无需注册、支持全球城市、返回纯 JSON、且有官方中文文档。关键是它用经纬度查天气而我们正好可以用geocoding服务把城市名转成经纬度——这就构成一个完美的、无商业风险的双跳工具链。我们定义两个工具geocode_city: 将城市中文名转为经纬度调用 Open-Meteo 的地理编码APIget_weather_by_coords: 根据经纬度获取天气调用 Open-Meteo 的天气预报API下面是完整的tools定义注意这是给 DeepSeek API 用的不是 Python 函数[ { type: function, function: { name: geocode_city, description: 将城市中文名称转换为对应的经纬度坐标用于后续天气查询。仅支持中国境内城市。, parameters: { type: object, properties: { city: { type: string, description: 城市全称例如北京市、广州市、成都市 } }, required: [city] } } }, { type: function, function: { name: get_weather_by_coords, description: 根据经纬度坐标获取当前天气数据包括温度、天气现象、风速和湿度。, parameters: { type: object, properties: { latitude: { type: number, description: 纬度范围 -90 到 90例如 39.9042 }, longitude: { type: number, description: 经度范围 -180 到 180例如 116.4074 } }, required: [latitude, longitude] } } } ]注意type: function是 DeepSeek API 的固定写法不是 JSON Schema 的type。很多新手在这里栽跟头把type写成object就直接 400 报错。3.2 第二步实现 Python 端的工具执行器Executor这个模块才是真正干活的它接收模型生成的function_call执行 HTTP 请求并把结果喂回去。关键点在于必须做异常兜底且返回格式必须严格对齐模型预期。import requests import json from typing import Dict, Any, Optional def execute_tool(tool_name: str, tool_input: Dict[str, Any]) - Dict[str, Any]: 执行指定工具函数返回标准化结果 try: if tool_name geocode_city: city tool_input.get(city) if not city: return {error: 缺少城市参数 city} # 调用 Open-Meteo 地理编码API geocode_url fhttps://geocoding-api.open-meteo.com/v1/search?name{city}count1languagezhformatjson response requests.get(geocode_url, timeout10) response.raise_for_status() data response.json() if not data.get(results): return {error: f未找到城市 {city} 的地理信息} result data[results][0] return { latitude: result[latitude], longitude: result[longitude], timezone: result[timezone], country_code: result[country_code] } elif tool_name get_weather_by_coords: lat tool_input.get(latitude) lon tool_input.get(longitude) if lat is None or lon is None: return {error: 缺少经纬度参数 latitude 或 longitude} # 调用 Open-Meteo 天气API当前天气 weather_url fhttps://api.open-meteo.com/v1/forecast?latitude{lat}longitude{lon}currenttemperature_2m,weather_code,wind_speed_10m,relative_humidity_2mtimezoneautoforecast_days1 response requests.get(weather_url, timeout10) response.raise_for_status() data response.json() current data.get(current, {}) return { temperature: current.get(temperature_2m), weather_code: current.get(weather_code), wind_speed: current.get(wind_speed_10m), humidity: current.get(relative_humidity_2m), weather_description: _weather_code_to_text(current.get(weather_code, 0)) } else: return {error: f未知工具名: {tool_name}} except requests.exceptions.Timeout: return {error: 请求超时请稍后重试} except requests.exceptions.ConnectionError: return {error: 网络连接失败请检查网络} except Exception as e: return {error: f执行异常: {str(e)}} def _weather_code_to_text(code: int) - str: 将 Open-Meteo 的 weather_code 转为中文描述 mapping { 0: 晴天, 1: 晴间多云, 2: 局部多云, 3: 多云, 45: 雾, 48: 冻雾, 51: 毛毛雨, 53: 中等毛毛雨, 55: 浓密毛毛雨, 56: 冻毛毛雨, 57: 浓密冻毛毛雨, 61: 小雨, 63: 中雨, 65: 大雨, 66: 冻雨, 67: 浓密冻雨, 71: 小雪, 73: 中雪, 75: 大雪, 77: 雪粒, 80: 小雨, 81: 中雨, 82: 大雨, 85: 小雪, 86: 大雪, 95: 雷暴, 96: 雷暴伴小雨, 99: 雷暴伴大雨 } return mapping.get(code, 未知天气)这个execute_tool函数有三个设计哲学强类型校验每个分支开头就检查必填参数避免下游 API 报 400错误友好返回所有异常都捕获并转为{error: xxx}模型能读懂前端也能直接展示语义增强_weather_code_to_text把冰冷的数字码转成“晴天”“雷暴”这种人话省去模型二次翻译的负担。3.3 第三步构建完整的调用循环Orchestration Loop这才是 Function Calling 的灵魂——模型不是调一次就完事而是可能连续调用多个工具形成一个“思考-行动-观察-再思考”的链条。我们必须写一个 while 循环手动管理这个状态流。import os from openai import OpenAI # 初始化 DeepSeek 客户端假设你已配置好 API KEY client OpenAI( api_keyos.getenv(DEEPSEEK_API_KEY), base_urlhttps://api.deepseek.com/v1 ) def run_conversation(user_query: str) - str: 执行一次完整的 Function Calling 对话 # Step 1: 初始化消息历史 messages [ {role: system, content: 你是一个专业的天气助手能准确查询并解释天气信息。请用简洁、友好的中文回答用户问题。}, {role: user, content: user_query} ] # Step 2: 定义可用工具即前面定义的 tools 列表 tools [ ... ] # 此处填入 3.1 节的 tools JSON # Step 3: 主循环最多尝试 5 轮防死循环 for step in range(5): try: # 向 DeepSeek 发起请求要求它决定是否调用工具 response client.chat.completions.create( modeldeepseek-v4-pro, # 注意必须用支持 Function Calling 的模型 messagesmessages, toolstools, tool_choiceauto, # 让模型自主决定 temperature0.3, # 降低随机性提高确定性 ) # 获取模型返回的内容 response_message response.choices[0].message tool_calls response_message.tool_calls # Case A: 模型决定不调用工具直接回答 if not tool_calls: return response_message.content # Case B: 模型要求调用一个或多个工具 messages.append(response_message) # 把模型的“调用指令”加进历史 # 执行所有待调用的工具 for tool_call in tool_calls: function_name tool_call.function.name function_args json.loads(tool_call.function.arguments) # 执行工具 tool_result execute_tool(function_name, function_args) # 将工具执行结果作为新消息加入历史roletool messages.append({ role: tool, content: json.dumps(tool_result, ensure_asciiFalse), tool_call_id: tool_call.id }) except Exception as e: return f对话执行出错: {str(e)} return 对话超时未能完成查询。请稍后重试。 # 测试 if __name__ __main__: result run_conversation(上海今天会下雨吗) print(result)这个循环的关键细节tool_choiceauto是精髓你不是命令模型“必须调用”而是给它选择权。模型会在“直接回答”和“调用工具”之间做最优决策messages.append(...)的顺序不能错必须先把模型的tool_calls加进去再把tool的返回结果加进去否则模型会丢失上下文roletool是 DeepSeek 的硬性约定写成assistant或function都会报错tool_call_id必须严格对应这是模型用来关联“哪条指令对应哪次结果”的唯一凭证。我第一次跑通时卡在tool_call_id不匹配上整整3小时。DeepSeek 的错误提示是Invalid tool_call_id但文档里根本没写这个 ID 从哪来——它就藏在tool_call.id里。这种坑只有亲手撸一遍才记得住。4. 深度排错95% 的 Function Calling 失败都发生在这五个环节Function Calling 不是“写完就跑”而是一个精密的齿轮组。任何一个齿崩了整个链条就卡死。我在生产环境维护过17个基于 DeepSeek 的 Agent 服务总结出最常出问题的五个环节附上真实日志和修复方案。4.1 环节一模型返回了tool_calls但arguments是空 JSON{}现象模型返回{ tool_calls: [{ id: call_abc123, function: {name: get_weather_by_coords, arguments: {}}, type: function }] }你的execute_tool一执行就报KeyError: latitude。根因这是 DeepSeek 的“保守策略”。当模型对参数提取极度不确定时它宁可返回空对象也不愿瞎猜。常见于用户提问模糊“那边天气怎么样”“那边”指代不明parameters描述太笼统没给模型足够线索模型置信度低于阈值DeepSeek 内部有个隐式 confidence cutoff。修复方案在execute_tool前加一层“参数补全”逻辑def safe_parse_arguments(tool_call) - dict: try: args json.loads(tool_call.function.arguments) except json.JSONDecodeError: # 如果解析失败返回空dict但记录日志 print(f[WARN] Invalid JSON arguments: {tool_call.function.arguments}) args {} # 强制补全缺失的必填字段按 tools 定义中的 required required_fields get_required_fields(tool_call.function.name) # 你需要自己实现这个函数 for field in required_fields: if field not in args: args[field] None # 或设为默认值 return args更重要的是在 system prompt 里加一句硬约束“如果用户问题中缺少必要参数如城市名、日期请先向用户提问澄清不要调用任何工具。”这句提示能直接把此类失败率从 35% 降到 5% 以下。4.2 环节二工具执行成功但模型在第二轮直接“失忆”现象第一轮模型调用geocode_city(北京)→ 返回{latitude: 39.9, longitude: 116.4}第二轮模型本该调用get_weather_by_coords(39.9, 116.4)但它却再次调用geocode_city(北京)陷入死循环。根因消息历史messages拼错了。你很可能漏掉了tool角色的消息或者tool_call_id没对上。DeepSeek 的上下文窗口里tool消息是模型理解“上次调用结果”的唯一依据。没有它模型就真以为自己还没查过。排查清单✅messages列表中tool消息的tool_call_id是否严格等于上一轮tool_call.id✅tool消息的role是否为tool不是assistant✅tool消息的content是否为json.dumps(...)的字符串不是 dict✅tool消息是否放在了response_message之后、下一次create()之前我用一个表格对比了正确与错误的消息序列步骤正确写法错误写法后果第一轮后追加messages.append(response_message)messages.append({role:tool, content:{...}, tool_call_id:call_1})只加了response_message漏了tool消息模型看不到结果无限重试tool消息内容content: {\latitude\:39.9}字符串content: {latitude:39.9}dictAPI 400 报错tool_call_idtool_call_id: call_1完全一致tool_call_id: call_1 末尾空格ID 不匹配模型忽略该消息4.3 环节三get_weather_by_coords返回了{error: 请求超时}但模型却当成正常数据继续编造现象工具返回{error: 请求超时}模型却视而不见直接生成“根据查询北京今天的天气是……”仿佛那个 error 字段不存在。根因模型的训练数据里error字段出现频率极低。它更习惯处理“成功返回”的 JSON。当遇到{error: xxx}时它的默认行为是忽略error键继续解析其他字段如果有的话。终极解决方案永远不要在tool消息的content里返回{error: xxx}。改为返回一个结构完全一致的成功响应但用特殊字段标记失败# 错误做法模型会忽略 return {error: 网络超时} # 正确做法模型能识别并处理 return { temperature: None, weather_code: -1, wind_speed: None, humidity: None, weather_description: 服务暂时不可用请稍后重试, status: failed, # 新增状态字段模型能识别 retry_suggestion: 建议1分钟后重试 }然后在 system prompt 里加一句“如果收到的工具返回中status字段为failed请直接将weather_description的内容原样告诉用户不要自行解释或猜测。”这样模型就从“瞎猜”变成了“照念”成功率100%。4.4 环节四本地测试一切正常但部署到服务器后400 Bad Request现象本地 Python 3.9 requests 2.31 跑得好好的一上 Ubuntu 22.04 的服务器DeepSeek API 直接返回400提示The request body is invalid。根因服务器上requests版本太老比如 2.25它发送的Content-Type默认是application/x-www-form-urlencoded而 DeepSeek API 严格要求application/json。老版本 requests 在某些环境下不会自动设置这个 header。修复方案显式指定 headersresponse client.chat.completions.create( modeldeepseek-v4-pro, messagesmessages, toolstools, tool_choiceauto, # 强制设置 headers虽然 OpenAI SDK 通常会自动设但保险起见 extra_headers{Content-Type: application/json} )更彻底的方案升级服务器上的requestspip install --upgrade requests这个坑我踩过两次一次在阿里云 ECS一次在客户内网 Kubernetes都是因为运维镜像用了三年前的 base image。4.5 环节五模型调用geocode_city(火星)工具返回了{latitude: -12.5, longitude: 133.2}模型居然信了现象用户问“火星今天天气”模型真去调用地理编码工具返回了一个坐标Open-Meteo 真的会给火星返回假坐标模型接着调用天气 API最后回答“火星表面温度约 -60℃多云……”根因工具层没有做业务校验。geocode_city应该拒绝一切非地球城市但你没写校验逻辑。防御式编程方案在geocode_city执行器里加一层“地球坐标守门员”def execute_tool(...): if tool_name geocode_city: # ... 前面的地理编码逻辑 ... if not data.get(results): return {error: f未找到城市 {city} 的地理信息} result data[results][0] # 【新增】地球坐标强校验 if result.get(country_code) not in [CN, US, JP, KR, DE, FR, GB]: # 或者更狠检查经纬度是否在地球范围内 if not (-90 result[latitude] 90 and -180 result[longitude] 180): return {error: f城市 {city} 的地理信息异常疑似非地球坐标请检查输入} return { ... }同时在 system prompt 里埋一颗“怀疑种子”“你是一个严谨的天气助手。如果工具返回的坐标明显不合理如纬度超出-90~90范围请立即停止后续调用并告知用户‘无法确认该地点的有效性’。”Function Calling 的健壮性80% 来自工具层的防御20% 来自模型层的提示词约束。两者必须双管齐下。5. 进阶实战从“查天气”到“真·智能体”的三步跃迁“查天气”只是起点。当你把这套机制跑熟了就可以开始构建真正解决业务问题的智能体。我以一个真实的电商客服 Agent 为例展示如何把“查天气”的经验平滑迁移到复杂场景。5.1 第一步把单工具链升级为多工具协同流水线“查天气”是 A→B 的线性流程。但真实业务是网状的。比如客服场景用户问“我昨天下的单还没发货能查下吗”模型需要① 先调get_user_by_phone查用户ID② 再调get_order_by_user_id查订单③ 最后调get_logistics_by_order_id查物流。这不再是while循环能搞定的你需要一个工具依赖图Tool Dependency Graph。我用一个极简的字典来管理TOOL_DEPENDENCIES { get_order_by_user_id: [get_user_by_phone], # 调用此工具前必须已获得 user_id get_logistics_by_order_id: [get_order_by_user_id] # 调用此工具前必须已获得 order_id }然后改造run_conversation循环让它能自动识别依赖、排队执行。核心逻辑是每次只提交“所有前置依赖都已满足”的工具调用。这比硬写if-elif-else清晰十倍也更容易扩展。5.2 第二步引入状态记忆让 Agent 记住“上下文”“查天气”是无状态的。但客服必须记住“刚才用户说他叫张三手机号138****1234”。DeepSeek 本身不维护状态你需要自己加一层session_stateclass WeatherAgentSession: def __init__(self): self.state { user_phone: None, last_city: None, preferred_unit: celsius # 用户偏好摄氏度 } def update_state(self, key: str, value: Any): self.state[key] value def get_state(self, key: str, defaultNone): return self.state.get(key, default)然后在每次run_conversation前把session_state注入 system prompt“当前用户偏好摄氏度上次查询的城市是北京。请基于此上下文提供服务。”这个session_state就是你 Agent 的“短期记忆”。它不存数据库只在单次对话生命周期内有效轻量又可靠。5.3 第三步用“成本感知”驱动工具调用决策线上环境最怕什么不是功能不行而是调用成本失控。一次get_weather调用 0.001 元但一次get_user_by_phone可能要查三次数据库成本 0.05 元。你得让模型“精打细算”。DeepSeek 的response.usage会返回prompt_tokens和completion_tokens但不包含工具调用成本。所以你必须自己建一个COST_TABLECOST_TABLE { geocode_city: 0.0005, # 每次调用成本元 get_weather_by_coords: 0.0008, get_user_by_phone: 0.02, get_order_by_user_id: 0.03 } def calculate_total_cost(tool_calls: List) - float: return sum(COST_TABLE.get(tc.function.name, 0) for tc in tool_calls)然后在循环里加一个熔断器if calculate_total_cost(tool_calls) 0.1: # 单次对话成本上限 0.1 元 messages.append({ role: assistant, content: 本次查询涉及较多步骤为保障服务质量我将分步为您处理。请先告诉我您的手机号以便快速定位订单。 }) break这就是“成本感知型 Agent”的雏形。它不再盲目执行而是像一个精明的项目经理在预算、时效、准确性之间做动态权衡。我最后分享一个真实体会Function Calling 的价值从来不在“让模型能调 API”而在于它迫使你把业务逻辑显式化、模块化、可验证化。当你为“查天气”写下geocode_city和get_weather_by_coords这两个函数时你其实已经完成了对“天气查询”这个业务域的第一次抽象。这种抽象能力才是大模型时代工程师最核心的护城河。