一、引言:只会聊天的 AI,价值少了一半
先看一个真实场景。你在 Dify 里搭了一个 DeepSeek 客服助手,用户问"帮我查一下订单 O20240901001 的物流状态",模型答得彬彬有礼:"请您提供订单号,我为您查询。"------可订单号明明就在问题里。再问"顺便把发票发到我邮箱",它又答:"抱歉,我暂时无法发送邮件。"
问题出在哪?出在模型只会"说",不会"做"。它没有手,够不到你的订单系统、邮件系统、数据库。而函数调用(Function Calling / Tool Use)就是给 AI 装上"手"的关键技术:让模型在对话中主动声明"我需要调用某个函数",再由你的程序真正执行,把结果喂回去,模型据此继续作答。
本文基于华为云 MaaS 平台的 DeepSeek 大模型推理服务与 Flexus 云服务器实战环境,从原理到代码、从单函数到多工具、从 Demo 到生产级工程,完整走一遍 DeepSeek 函数调用的落地路径。读完你将能:
- 讲清楚函数调用为什么是 Agent 的地基;
- 用 OpenAI 兼容的 tools 协议把 DeepSeek 接入自己的工具;
- 用 Dify 的可视化编排快速搭一个带工具的助手;
- 避开并发、鉴权、超时、工具幻觉等生产级大坑。
二、先搞懂原理:函数调用到底是怎么工作的
2.0 为什么是 JSON Schema,而不是自然语言描述
细心的读者会发现,工具声明里的 parameters 用的是 JSON Schema 规范------而不是让开发者用自然语言写"这个函数接收一个城市名"。原因有三:
- 机器可校验:JSON Schema 可以程序化地校验模型生成的参数是否合法,缺了必填字段、类型给错,你的代码能第一时间发现并让模型重试;
- 模型更听话:DeepSeek 在训练阶段就见过海量 JSON Schema 格式的指令数据,用它的"母语"描述工具,指令遵循的准确率明显高于自由文本;
- 生态互通:OpenAI 系、Anthropic 系、各大云厂商的工具调用协议全部基于 JSON Schema,一套声明可以在不同模型间无损迁移------你在 MaaS 上写的 tools,换到任何兼容模型上都能用。
所以别嫌 Schema 写法啰嗦,它其实是整个函数调用体系里"最值得认真写"的部分:工具声明写得好不好,直接决定模型调用得准不准。
2.1 一句话定义
函数调用不是模型去执行代码,而是模型根据用户意图,从你声明的函数清单里选出一个(或几个),并生成符合规范的调用参数 。真正执行的是你的程序。模型全程"只动嘴,不动手"------动手的是你写的那几行 execute 代码。
2.2 一次完整调用要经过四步
| 步骤 | 谁在做 | 干什么 |
|---|---|---|
| ① 声明函数 | 开发者 | 把函数名、描述、参数 JSON Schema 传给模型 |
| ② 模型决策 | 模型 | 判断该调哪个函数、参数填什么,返回 tool_calls |
| ③ 执行函数 | 你的程序 | 真正去查数据库、调 API、发邮件 |
| ④ 回传结果 | 开发者 | 把函数返回值以 tool 消息发回,模型生成最终回答 |
关键认知:第 ② 步模型只输出"调用意图和参数",不保证参数一定正确、也不保证函数真实存在------这正是后面要讲的"工具幻觉"问题的根源。
2.3 为什么 DeepSeek 的函数调用值得单开一篇
早在 DeepSeek-V2.5 时代,官方就在 API 中开放了 function calling 能力;到了 DeepSeek-V3 / R1 系列,工具调用的指令遵循能力大幅提升:能同时发起多个工具调用(parallel tool calls)、能正确处理多轮工具结果、能在工具结果异常时给出合理的兜底回复。加上 MaaS 平台上按 Token 计费、开箱即用的推理服务,函数调用场景的成本门槛被压得很低------高频工具调用场景下,一次完整的多轮工具对话往往只要几分钱。
但能力强不代表不会犯错。接下来我们直接从代码入手,把正确姿势和错误姿势都过一遍。
三、环境准备:MaaS 上拿到 DeepSeek 的 API
3.1 三步拿到推理服务
- 登录华为云 ModelArts Studio(MaaS)控制台,进入"模型服务"页面;
- 选择 DeepSeek 系列模型(V3 或 R1,按场景选:工具调用与多轮对话优先 V3,复杂推理优先 R1),开通"推理服务";
- 在"API 凭证"里生成 API Key,记下服务的基础 URL(形如
https://xxx.maas.com/v1)与模型名。
MaaS 提供的是 OpenAI 兼容接口 ,这意味着市面上几乎所有为 OpenAI 写的 SDK 和框架(OpenAI Python SDK、LangChain、Dify、LiteLLM 等)都可以无缝切换过来,只需改 base_url 和 api_key。这是函数调用生态能够"一次开发、处处复用"的最大红利。
3.2 最小验证代码
from openai import OpenAI
client = OpenAI(
base_url="https://你的MaaS服务地址/v1", # MaaS 的 OpenAI 兼容端点
api_key="你的API-KEY",
)
resp = client.chat.completions.create(
model="deepseek-v3", # MaaS 上开通的模型名
messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)
跑通这一步,环境就绪。下面进入正题:给模型"装手"。
四、从零写函数调用:一个查天气的助手
4.1 第一步:声明一个函数
函数调用的一切始于"告诉模型你有什么工具"。我们用 OpenAI 兼容的 tools 参数声明一个查天气的函数。注意:描述要写得像给一个聪明但没见过你系统的实习生看------他需要知道什么时候用、每个参数是什么意思、填什么格式。
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市当前的天气情况,包括温度、天气现象和风力。当用户询问天气时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京、上海、深圳",
}
},
"required": ["city"],
},
},
}
]
4.2 第二步:真正的函数(模型不执行,你执行)
函数声明是"给模型看的说明书",真正的执行逻辑在你自己这边:
def get_weather(city: str) -> str:
"""真实执行:这里应调用天气 API,demo 用假数据"""
table = {"北京": "晴 26℃ 微风", "上海": "多云 29℃ 东南风3级", "深圳": "雷阵雨 31℃ 南风2级"}
return table.get(city, f"暂无 {city} 的天气数据")
4.3 第三步:完整的调用循环
这是函数调用最核心的模板,所有单工具场景都是它的变体:
def chat_with_tools(messages):
# 第一轮:把消息和工具清单一起发给模型
resp = client.chat.completions.create(
model="deepseek-v3",
messages=messages,
tools=tools,
tool_choice="auto", # auto=让模型自己决定要不要调
)
msg = resp.choices[0].message
messages.append(msg) # 把模型的回复(可能含 tool_calls)追加进历史
# 如果模型想调工具
if msg.tool_calls:
for tc in msg.tool_calls:
fn = tc.function
if fn.name == "get_weather":
import json
args = json.loads(fn.arguments) # 参数是 JSON 字符串
result = get_weather(args["city"]) # 真正执行
# 把工具结果以 role=tool 发回,tool_call_id 必须对应
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False),
})
# 第二轮:让模型基于工具结果生成最终回答
resp = client.chat.completions.create(
model="deepseek-v3", messages=messages, tools=tools)
messages.append(resp.choices[0].message)
return messages[-1].content
4.4 跑起来看效果
messages = [{"role": "user", "content": "北京今天适合出门吗?需要带伞吗?"}]
print(chat_with_tools(messages))
模型会先输出一个 tool_calls(调用 get_weather,参数 {"city": "北京"}),你的代码执行完把结果回传,模型最终回答:"北京今天晴、26℃、微风,适合出门,不需要带伞。"------注意,回答里的"适合出门"是模型基于真实工具结果 推理的,不是瞎编的。这就是函数调用的价值:让模型的每一个结论都长在真实数据上。
4.5 最容易踩的三个坑(新手必看)
- 忘记追加历史 :
tool_calls那条 assistant 消息和tool结果消息必须按顺序 进messages,否则模型不知道工具结果是回答谁的。顺序是:assistant(含tool_calls) → tool(结果) → assistant(最终回答)。 tool_call_id对不上 :多工具并行时,每个tool消息必须带自己对应的tool_call_id,张冠李戴会让模型直接"精神错乱"。- 参数是字符串不是对象 :
fn.arguments是 JSON 字符串,必须先json.loads再取字段 。忘了解析会得到'{"city": "北京"}'这种没法用的东西。
五、进阶:多工具与并行调用
5.0 工具描述撰写的最佳实践
工具多了以后你会发现:模型"选错工具"和"参数填错",一半以上是开发者描述写得含糊。三条经验值直接给到:
- 触发条件写具体:"当用户询问订单信息时使用"比"查询订单"好用得多。要写清楚"什么时候用、什么时候不用"------比如查询订单工具里注明"仅用于已下单用户,咨询下单流程请勿使用",能显著减少误调用;
- 参数说明给格式和示例 :
order_id的描述写成"订单号,形如 O20240901001,以 O 开头共 12 位",模型填参的正确率会大幅提升。示例是最好的约束; - 名称用动词短语 :
query_order、create_after_sale比order、as这种模糊命名更利于模型区分相近工具。工具一多,命名风格就是隐形的分类器。
下面这段对比能直观说明问题。模糊描述:
{"name": "get", "description": "查信息", "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}}
清晰描述:
{"name": "query_order_status", "description": "按订单号查询订单当前状态与物流进度,用户询问'我的订单到哪了/发货没'时使用", "parameters": {"type": "object", "properties": {"order_id": {"type": "string", "description": "订单号,以 O 开头共 12 位,如 O20240901001"}}, "required": ["order_id"]}}
同样的模型、同样的输入,后者的工具调用成功率能拉开十个百分点以上。工具描述不是写给机器看的注释,而是写给模型看的"使用说明书"。
5.1 一次声明多个工具
真实业务里不可能只有一个工具。订单查询、物流跟踪、售后登记......我们一次声明三个,让模型自己选:
tools = [
{"type": "function", "function": {"name": "get_weather", ...}},
{"type": "function", "function": {
"name": "query_order",
"description": "按订单号查询订单状态、金额、商品明细。当用户询问订单信息时使用。",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string", "description": "订单号,形如 O20240901 开头"},
}, "required": ["order_id"]},
}},
{"type": "function", "function": {
"name": "send_email",
"description": "向指定邮箱发送邮件。当用户要求发送邮件/发票/报表时使用。",
"parameters": {"type": "object", "properties": {
"to": {"type": "string"}, "subject": {"type": "string"}, "body": {"type": "string"},
}, "required": ["to", "subject", "body"]},
}},
]
5.2 并行调用:模型一次"想好"多步
用户问:"帮我查一下订单 O20240901001 的状态,顺便看看北京明天天气,天气不好就发邮件提醒我。"------模型可能同时发起 query_order 和 get_weather 两个调用。你的代码要做的是:遍历所有 tool_calls,逐个执行,全部结果回传,再让模型综合回答 。前面的 chat_with_tools 循环天然支持并行(for tc in msg.tool_calls 就是为并行准备的)。
并行调用是 DeepSeek-V3 系模型工具能力的亮点:一次推理同时输出多个结构化调用,既省时间又省 Token(少了一轮"先问你下一步"的往返)。
5.3 工具路由:把函数名映射到真实实现
函数多了以后,硬编码 if fn.name == "xxx" 会越来越丑。用一个注册表统一管理:
TOOL_REGISTRY = {
"get_weather": get_weather,
"query_order": query_order,
"send_email": send_email,
}
def dispatch(name: str, args_json: str) -> str:
import json
fn = TOOL_REGISTRY.get(name)
if fn is None:
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
args = json.loads(args_json) if args_json else {}
result = fn(**args)
return json.dumps(result, ensure_ascii=False, default=str)
以后加新工具 = 写一个函数 + 加一行注册 + 加一段声明,主循环一行都不用改。这就是"声明(tools)与实现(registry)分离"的工程红利。
六、工具幻觉:模型"编造"工具与参数怎么办
6.1 什么是工具幻觉
模型可能:① 调用了你根本没声明的函数名;② 参数格式错误(该给数字给字符串);③ 参数值编造(订单号不存在也照填);④ 明明没工具可用,却假装调用了。前两种是协议层问题,后两种是语义层问题。
6.2 防御三板斧
第一板斧:声明即边界。 只把 tools 里声明过的函数放进注册表,dispatch 里对未知工具一律返回错误 JSON------绝不能让模型"发明"函数去执行。
第二板斧:结果可失败。 工具执行失败不要抛异常裸奔,返回结构化错误让模型自己"圆场":
def query_order(order_id: str) -> str:
if not order_id.startswith("O"):
return json.dumps({"error": "订单号格式不正确,应以 O 开头"}, ensure_ascii=False)
# 查库逻辑...
模型收到 {"error": ...} 后,通常会主动道歉并向用户解释或引导纠正------这比程序直接崩溃体验好一百倍。
第三板斧:关键动作要人审。 凡是"花钱、发消息、删数据"这类不可逆操作(如 send_email、transfer_money),生产环境务必加确认环节:先让模型把参数整理给用户确认,用户点头后程序才真正执行。这条在 Dify 里可以直接用"节点间人工确认"实现。
七、工程化落地:并发、超时与可观测性
7.1 三个必须处理的工程问题
| 问题 | 表现 | 解法 |
|---|---|---|
| 并发限流 | 工具多轮循环里大量请求,触发 MaaS 限流 | 连接复用 + 指数退避重试 + 并发池上限 |
| 超时失控 | 模型或工具长时间无响应,用户干等 | 分层超时:LLM 调用、工具执行、整轮对话各自设超时 |
| 黑盒难查 | 出了问题不知道是模型选错还是工具执行错 | 全链路日志:每次调用的输入输出、耗时、Token 数全部落盘 |
7.2 一个稳健的执行器骨架
import json, time
from openai import OpenAI
class ToolAgent:
def __init__(self, client, tools, registry, timeout=30, max_rounds=5):
self.client = client
self.tools = tools
self.registry = registry
self.timeout = timeout # 单次模型调用超时
self.max_rounds = max_rounds # 工具往返上限,防死循环
def run(self, user_input: str) -> str:
messages = [{"role": "user", "content": user_input}]
for _ in range(self.max_rounds):
resp = self.client.chat.completions.create(
model="deepseek-v3", messages=messages,
tools=self.tools, timeout=self.timeout)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content # 不再调工具 → 结束
for tc in msg.tool_calls:
fn, args = tc.function.name, tc.function.arguments
result = self.registry[fn](json.loads(args)) if fn in self.registry \
else json.dumps({"error": f"未知工具 {fn}"})
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False, default=str)})
return "已达到最大工具调用轮数,请简化问题后重试"
max_rounds 是保命符:模型在复杂任务里可能"调了又调"停不下来,没有轮数上限,一次对话能烧掉你几十次调用。
7.3 可观测性清单
生产环境请至少记录:每次 LLM 调用的 prompt_tokens / completion_tokens(成本核算)、工具名与参数(审计)、执行耗时(性能监控)、tool_calls 数量与轮数(防失控)。成本侧有个经验值:加了工具声明的请求,单次 Token 消耗通常比纯对话高 10%-30% ,因为 tools 描述每次都要随请求发出去------工具声明写得精炼,本身就是省钱。
7.4 幂等性:工具重试的安全带
并发与超时带来的下一个问题是重复执行:一次调用超时了,你重试,但上一次其实已经在订单系统里建好了工单------用户就收到了两张售后单。解法是让"会改变状态"的工具具备幂等性:
- 业务幂等键 :
create_after_sale接收一个由调用方生成的request_id(可用订单号+时间戳+随机数拼成),系统按request_id去重,重复请求直接返回第一次的结果; - 查询类天然安全 :
query_order、get_weather这类只读工具没有副作用,超时直接重试即可,不用额外设计; - 先查后写:写操作前先调用一次查询确认当前状态,避免"重复建单""重复扣款"这类业务事故。
给 Agent 接工具时,请先给你的工具做一次"副作用体检":只读还是写操作?写操作能不能安全重试? 想清楚这两问,再谈并发上限。
7.5 工具结果校验:别把脏数据喂给模型
工具返回的数据五花八门:数据库字段可能是 None、日期可能是 2026-09-04T00:00:00Z 这种用户看不懂的格式、金额可能是分单位的整数。直接回传会让模型"照着脏数据说胡话"。在 dispatch 里加一道清洗层:空值转成"暂无"、时间格式化成 2026-09-04、金额除以 100 转成元并保留两位小数。模型回答质量的天花板,是你喂给它的工具结果质量。
八、Dify 里的函数调用:不写代码也能编排
8.1 为什么还要用 Dify
上面纯代码方案灵活,但每次加工具都要改代码、发版。Dify(华为云 Flexus 云服务器上可一键部署)把函数调用做成了可视化编排:模型节点里声明工具、HTTP 节点或代码节点里实现执行逻辑、再加判断分支和人工确认,拖拽即成。适合快速验证和中后台场景。
8.2 在 Dify 中搭建"订单助手"的步骤
- 创建应用 :新建"聊天助手"类型应用,模型选择 MaaS 上的 DeepSeek(在 Dify 里配置自定义模型供应商,填
base_url和 API Key); - 添加工具 :在"工具"页添加自定义工具,用 OpenAPI Schema 描述
query_order(本质还是 JSON Schema,只是换成了 OpenAPI 格式),或直接添加 HTTP 工具指向你自己的查询接口; - 编排流程:聊天入口 → LLM 节点(带工具)→ 若模型输出工具调用,走工具执行节点 → 把结果回填给 LLM 生成回答;
- 加人工确认 :在
send_email这类动作后加"对话流/工作流"的暂停节点,用户确认后才发信; - 发布上线:发布为 Web App 或通过 API 接入你的客服系统。
8.3 代码与 Dify 怎么选
| 维度 | 纯代码方案 | Dify 编排方案 |
|---|---|---|
| 上手门槛 | 需要写 Python | 拖拽即可,业务同学也能改 |
| 灵活性 | 极高,任意逻辑 | 受节点类型限制 |
| 调试体验 | 靠日志 | 可视化查看每步输入输出 |
| 适合场景 | 复杂 Agent、深度定制 | 快速落地、工具流程固定、需要人审 |
我的建议:先代码把原理跑通,再用 Dify 提效 。两者不是替代关系------你手写的 dispatch 逻辑,在 Dify 里就是那根"工具节点"的连线。
九、R1 与 V3:推理模型做工具调用要注意什么
9.1 两个模型的定位差异
MaaS 上 DeepSeek 系列里,V3 是通用对话与工具调用的主力:响应快、成本低、多工具并行稳;R1 是推理增强模型,适合数学、逻辑、复杂规划,但推理过程长、首字延迟高。函数调用场景的朴素经验是:
- 纯工具调用(查个订单、问个天气)→ V3,又快又省;
- 需要先规划再调用("比较这三份方案的成本差异并整理成邮件")→ R1,先想清楚再动手;
- R1 做工具调用时 :把
reasoning输出单独存日志(R1 的思考过程本身有 debug 价值),并且注意它的工具参数有时会带"思考痕迹",json.loads前先做清洗。
9.2 实测经验:工具结果太长怎么办
工具返回一大段 JSON(比如订单明细 + 物流轨迹 + 售后记录),直接全文回传给模型,既费 Token 又可能让模型"抓不住重点"。两个技巧:
- 截断/摘要:工具侧只返回模型回答所需的核心字段,明细放附件或链接;
- 分页:一次只回前 N 条,告诉模型"如需更多请再调用一次带 page 参数"。
让工具返回"够用且精炼"的结果,是函数调用省钱提质的隐藏杠杆。
十、实战案例:把整套串起来
10.1 场景
做一个"售后小助手":用户报订单号 → 查订单 → 判断是否在售后期内 → 在期内引导登记、超期则说明原因 → 全程可溯源。
10.2 完整代码(核心片段)
tools = [
{"type": "function", "function": {"name": "query_order",
"description": "按订单号查询订单状态、购买时间、金额", "parameters": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "订单号"}},
"required": ["order_id"]}}},
{"type": "function", "function": {"name": "create_after_sale",
"description": "为符合条件的订单创建售后工单", "parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"reason": {"type": "string", "description": "用户报修/退货原因"},
}, "required": ["order_id", "reason"]}}},
]
def query_order(order_id: str):
# demo: 真实场景查数据库/订单API
return {"order_id": order_id, "status": "已签收",
"buy_time": "2026-08-20", "amount": 1299.0}
def create_after_sale(order_id: str, reason: str):
# demo: 真实场景写工单系统
return {"ticket_id": "AS20260904001", "order_id": order_id, "status": "已创建"}
registry = {"query_order": query_order, "create_after_sale": create_after_sale}
agent = ToolAgent(client, tools, registry) # 复用 7.2 的执行器
print(agent.run("我的订单 O20240901001 屏幕碎了,能售后吗?"))
一次典型的执行轨迹:
[第1轮] 模型: tool_calls=[query_order(O20240901001)]
[执行] 查订单 → 返回 已签收/2026-08-20
[第2轮] 模型: tool_calls=[create_after_sale(O20240901001, 屏幕碎裂)]
[执行] 建工单 → 返回 AS20260904001
[第3轮] 模型: 最终回答 "已为您创建售后工单 AS20260904001,售后专员将..."
注意看:模型自己完成了"查→判断→办"的决策链,你只提供了工具和规则。这就是 Agent 的最小闭环。
10.3 如果查询结果是"订单不存在"
模型收到 {"error": "订单不存在"} 后,会自然回答:"抱歉,没有查到该订单,请核对订单号是否以 O 开头。"------没有崩溃、没有幻觉编造物流信息,这就是 6.2 节结构化错误的威力。
十一、总结与行动清单
11.1 本文核心结论
- 函数调用 = 模型出意图、程序做执行 :模型只生成工具名和参数,执行权永远在你这边的
dispatch里; - 协议是 OpenAI 兼容的 tools:MaaS 上的 DeepSeek 用同一套 Schema,生态工具全兼容;
- 循环模板是地基 :assistant(tool_calls) → tool(结果) → assistant(最终),顺序与
tool_call_id不能错; - 防御要前置:声明即边界、结果可失败、关键动作人审,三板斧挡掉 90% 的工具幻觉;
- 工程三件套:超时、轮数上限、全链路日志,缺一个都别上生产;
- 选型有讲究:纯调用用 V3,复杂规划用 R1;代码灵活、Dify 高效,先懂原理再谈编排。
11.2 一句话记住本文
给 DeepSeek 装"手"的正确姿势:声明好工具、写好分发器、设好护栏,然后让模型自己决定怎么干活。
11.3 最后的话
函数调用是 2026 年 AI 应用开发者的"基础技能"------它把模型从"聊天框"里解放出来,接上你的数据库、API 和业务系统。本文的代码都在上面,建议你照着跑一遍:先单工具、再多工具、再上护栏,半小时就能感受到"模型开始替你干活"的质变。踩过什么坑、有什么骚操作,欢迎评论区聊聊!
十二、参考资源
- 华为云ModelArts Studio MaaS平台
- 华为云Flexus云服务器
- 华为云 MaaS 与 Flexus Dify 一键部署方案
- DeepSeek 官方 API 文档:Function Calling
- DeepSeek实战指南系列:从入门到企业级部署
写在最后 :你第一次给 DeepSeek 接上函数调用时,是顺利跑通还是被
tool_call_id折磨到凌晨?评论区交出你的故事,让后来者少踩一个坑!🤖