做了几年后端和模型应用我最大的感受是Function Calling 这块真正耗时间的从来不是模型调参而是给模型写 Tools 定义。尤其是当系统里躺着 50 个 REST API 的时候光把每个接口的参数、含义、边界情况翻译成模型能看懂的 JSON 结构就能写上一整天。写完了还得测测完了接口一改又得跟着改。所以当我尝试用 OpenAPI 文档来自动生成 DeepSeek 工具定义之后回头再看之前手动维护的那份 tools.json真的有一种“以前都在用锄头挖地”的感觉。这篇文章就完整聊聊这套做法为什么 REST API 和 Tools 之间需要一座桥OpenAPI 规范在这中间充当什么角色以及怎么用几十行 Python 把 50 个接口的 OpenAPI 文档一次性转换成 DeepSeek 可用的 Function Calling 工具定义。文章里会给出可以直接拿走的代码、字段映射规则、批量生成的优化手段还有我自己实际踩过的坑。适合正在做 Agent 开发、想把业务系统接入 DeepSeek 工具调用、或者厌倦了手工维护 tools 配置的开发者参考。1. 为什么 50 个 REST API 的手写 Tools 会让人崩溃1.1 手写 Tools 的真实工作量算一笔账先定义一下“手写 Tools 到底要写什么”。在 DeepSeek 的 Function Calling 体系里一个工具定义就是一个 JSON 对象核心结构长这样{ type: function, function: { name: get_order_detail, description: 根据订单ID查询订单详细信息包括商品列表、金额、状态、收货地址等, parameters: { type: object, properties: { order_id: { type: string, description: 订单编号格式如 DD20250101001 }, include_items: { type: boolean, description: 是否返回订单商品明细默认 true } }, required: [order_id] } } }一个接口对应一个这样的定义。看起来不难但问题在于数量。我们当时系统里查询类、操作类接口加起来正好 50 个每个接口平均 3~6 个参数有些接口的请求体还是嵌套结构。按平均一个工具定义 8 分钟算包括读接口文档、理清参数含义、组织描述、调整类型50 个就是 400 分钟接近 7 个小时。这 7 个小时里真正有价值的时间可能只有 1 小时——搞清楚每个接口是干什么的。剩下 6 小时全浪费在把同样的信息重复翻译成 JSON 上。更坑的是这 50 个接口还不是静态的。产品一迭代某个接口加个参数、改个字段类型你又得从 50 个文件里找到对应那个改完还得检查格式有没有写错。1.2 Function Calling 的工作原理模型如何决定调用哪个工具要理解为什么 Tools 定义的质量直接决定 Agent 的表现得先搞清楚 DeepSeek 这类模型在 Function Calling 时到底在做什么。你可以把 Tools 的 JSON 定义理解为“给模型的一份工具说明书”。当用户说“帮我把订单 12345 的物流信息查一下”时模型不会去调用任何真实 API——它只是根据你提供的工具说明书在内部判断“哪个工具能完成这件事”然后输出一个结构化的调用指令类似{ name: query_logistics, arguments: {\order_id\: \12345\} }注意模型输出的只是“调用意图”和“参数”真正去发 HTTP 请求的是你的应用代码。也就是说Tools 定义是模型了解外部世界的唯一窗口。如果这个窗口描述得不清楚模型可能选错工具、填错参数、漏掉必填项。这也是为什么光有工具名和参数列表不够每个参数的描述、为什么需要这个参数、参数之间有没有关联都得写清楚。而 OpenAPI 文档里恰好已经有这些信息了只是散落在各种字段里需要提取和重组。1.3 手写方式的三宗罪维护、一致性、分工手写 Tools 最大的三个问题我总结成“三宗罪”。第一宗罪是维护成本失控。接口文档是活的Tools 定义也是活的但它们之间没有自动同步机制。于是经常出现“文档更新了tools.json 忘了改”的情况。模型拿着过时的参数定义去调用接口轻则参数校验失败重则直接调错接口。第二宗罪是一致性无法保证。手写意味着每个人都有自己的翻译习惯。同一套接口A 写的 description 可能用中文B 写的可能用英文A 喜欢把参数拼接成一个嵌套对象B 喜欢全部摊平。几十个工具定义之间风格不统一模型的识别效果一定会打折扣。第三宗罪是分工协作困难。后端团队维护 OpenAPI 文档算法团队维护 Tools 定义两边天然是脱节的。如果 Tools 定义能直接从 OpenAPI 生成那职责边界就清晰了——后端保证接口契约准确算法只关注如何用好这些工具。这个思路其实就是我最终决定做自动生成的直接原因。2. OpenAPI 自动生成 Tools 的整体思路2.1 核心思路把 API 契约直接翻译成模型能读的说明书OpenAPI 是什么简单说它是一种描述 REST API 的标准化格式。用 OpenAPI 写的接口文档机器可以直接读取并解析。现在很多后端框架FastAPI、SpringDoc、Knife4j 等都支持自动导出 OpenAPI JSON 文件也就是说每个接口的路径、方法、参数、请求体、响应结构其实早就结构化了只是之前没有好好利用。我做的这件事本质上就是一个“翻译器”把 OpenAPI 文档中关于每个接口的描述信息按照 DeepSeek Tools 的 JSON 格式重新组织一遍。翻译器不产生新信息它只是搬运信息——把原本写给人看的接口契约改写成写给模型看的工具说明书。这样做的好处显而易见快50 个接口的 OpenAPI 文档解析加转换几秒钟完成。准所有字段类型、必填约束、参数含义都直接来自接口契约不会因为手写而产生翻译错误。好维护接口一更新重新跑一遍脚本新的工具定义就生成了。后端加了个新接口自动多一个 tool完全不用人工干预。2.2 为什么选 OpenAPI 而不是随便写个爬虫去扒接口可能有人会问“我不一定有 OpenAPI 文档能不能直接抓包或者让后端随便给个 JSON 列表”我的建议是如果没有 OpenAPI 文档先去和后端商量建立一个哪怕只覆盖常用接口。因为 OpenAPI 是 REST API 的标准描述方式它天然包含了 Tools 定义需要的一切要素路径参数、查询参数、请求体结构、参数类型、必填约束、接口说明。这些信息如果靠人工整理基本又回到了手写的坑里。而且 OpenAPI 有完善的工具链支持。解析$ref引用可以用jsonref、处理 JSON Schema 可以用jsonschema、验证文档结构可以用官方的 openapi-spec-validator。这些库都经历过大量生产环境验证比自己写一套解析逻辑靠谱得多。2.3 整体架构与数据流从 OpenAPI 文档到 DeepSeek 工具整个转换流程可以拆成四个阶段获取 OpenAPI 文档从后端服务地址拉取比如https://api.example.com/openapi.json或者直接读取本地 JSON 文件。解析文档结构提取paths下每个路径和 HTTP 方法对应的 operation 信息。映射字段把 operation 的parameters、requestBody、summary、operationId等字段映射到 DeepSeek tools 定义中对应的位置。输出工具集合生成统一的 tools.json 文件或者直接以 Python 数据结构形式输出方便后续接进业务流程。这个架构里最关键的是第 3 步。映射规则定得好不好直接决定生成的工具定义模型能不能看懂、参数解析准不准。3. 实操从 OpenAPI 到 DeepSeek Tools 的完整实现3.1 环境准备与依赖选型实现这套转换逻辑需要准备的基础环境如下Python 3.9我用的是 3.11低版本也没问题只要支持jsonrefrequests用于拉取远程 OpenAPI 文档jsonref用于解析 JSON Schema 中的$ref引用deepseek官方 SDK 或直接调用 HTTP APISDK 会省事很多但为了把逻辑讲透我会直接给出 API 结构的说明安装命令就一条pip install requests jsonref做一个简单的目录结构openapi_tools/ ├── generate_tools.py # 主脚本 ├── openapi.json # 从后端拉取的 OpenAPI 文档 └── output/ └── tools.json # 生成的 DeepSeek 工具定义3.2 解析 OpenAPI 文档路径、方法、参数、请求体先说明一下 OpenAPI 文档的手术对象。一份标准的 OpenAPI 文档核心结构如下{ openapi: 3.0.0, info: {title: 订单服务 API, version: 1.0.0}, paths: { /orders/{order_id}: { get: { operationId: getOrderDetail, summary: 查询订单详情, description: 根据订单ID查询订单的完整信息包括商品明细、金额、物流状态等, parameters: [ { name: order_id, in: path, required: true, schema: {type: string, description: 订单编号} }, { name: verbose, in: query, required: false, schema: {type: boolean, description: 是否返回完整字段默认false} } ], requestBody: { required: false, content: { application/json: { schema: { type: object, properties: { note: {type: string, description: 订单备注} } } } } } } } } }在这个示例里目标工具就是getOrderDetail需要两个参数order_id必填和verbose选填。我的解析策略是把parameters里的路径参数和查询参数合并成工具的参数列表把requestBody里的 schema 属性也合并进去。这三类来源汇总之后才是最终工具的全部参数。3.3 字段映射OpenAPI 参数如何变成 Tool 的 parameters这一步是整个方案的核心。我踩过很多次坑后总结出的映射规则如下OpenAPI 字段Tools 定义字段转换规则operationIdfunction.name直接使用但要清洗非法字符并限制长度summaryfunction.description优先用 summary缺失时拼接 method 和路径parameters[*].nameproperties的键名同名不同来源时加前缀避免冲突parameters[*].schema.typeproperties[*].type直接映射parameters[*].schema.descriptionproperties[*].description直接映射缺失时用参数名兜底parameters[*].requiredrequired数组只在为 true 时加入requestBody.content的 schemaproperties有请求体时按“根对象属性平铺”策略合并无对应字段required: []所有参数都选填时保留空数组需要注意一个细节required只在parameters数组中显式标记为true时合并进required列表。而请求体里的required是定义在 schema 内部的需要单独提取。我一开始忽略了这一点导致生成的工具有些参数明明是必填却标成了选填模型参数不齐接口直接返回 400。3.4 生成工具定义完整代码与说明下面是可以直接拿去用的完整脚本。我加了详细的注释方便按需修改。import json import re import requests import jsonref from typing import Any, Dict, List, Optional # OpenAPI 文档地址可以是 URL 也可以是本地文件路径 OPENAPI_URL https://api.example.com/openapi.json def load_openapi_document(source: str) - Dict[str, Any]: 加载 OpenAPI 文档并解析 $ref 引用。 if source.startswith(http): resp requests.get(source, timeout10) resp.raise_for_status() raw resp.json() else: with open(source, r, encodingutf-8) as f: raw json.load(f) # jsonref 会递归解析所有 $ref替换成实际的对象引用 return jsonref.replace_refs(raw) def sanitize_function_name(name: str) - str: 清洗函数名使其符合 DeepSeek 工具命名要求。 只保留字母、数字、下划线和连字符且最长 64 个字符。 # 根据实测名称中如果包含路径分隔符或特殊字符DeepSeek API 会返回错误 sanitized re.sub(r[^a-zA-Z0-9_-], _, name) sanitized sanitized[:64].strip(_) # 不要以数字开头 if sanitized[0].isdigit(): sanitized api_ sanitized return sanitized def extract_parameters( path: str, method: str, operation: Dict[str, Any] ) - Dict[str, Any]: 从 operation 的 parameters 中提取参数信息合并路径参数和查询参数。 properties {} required [] for param in operation.get(parameters, []): name param.get(name) param_schema param.get(schema, {}) param_type param_schema.get(type, string) param_desc param_schema.get(description, ) # 同一个参数名可能同时出现在 path 和 query 中加 in 前缀区分 if name in properties: name f{param.get(in)}_{name} properties[name] { type: param_type, description: param_desc or f{name} 参数, } if param.get(required, False): required.append(name) return {properties: properties, required: required} def extract_request_body(operation: Dict[str, Any]) - Dict[str, Any]: 从 requestBody 中提取参数信息。 策略把 JSON Schema 根对象的属性平铺成工具参数避免嵌套太深。 properties {} required [] request_body operation.get(requestBody) if not request_body: return {properties: properties, required: required} content request_body.get(content, {}) if application/json not in content: return {properties: properties, required: required} schema content[application/json].get(schema, {}) if schema.get(type) ! object: # 如果请求体不是对象比如纯数组或纯字符串用 request_body 作为参数名 properties[request_body] { type: schema.get(type, string), description: 请求体内容, } return {properties: properties, required: required} for name, prop_schema in schema.get(properties, {}).items(): properties[name] { type: prop_schema.get(type, string), description: prop_schema.get(description, ) or f{name} 参数, } required schema.get(required, []) return {properties: properties, required: required} def build_tool_definition( path: str, method: str, operation: Dict[str, Any] ) - Optional[Dict[str, Any]]: 根据单个 operation 构建 DeepSeek 工具定义。 # 优先使用 operationId如果没有用 method_pinyin_path 这种兜底方案 raw_name operation.get(operationId) or f{method}_{path.replace(/, _)} function_name sanitize_function_name(raw_name) # 描述信息优先取 summary它通常是一句话的接口说明 description ( operation.get(summary) or operation.get(description) or f{method.upper()} {path} ) path_params extract_parameters(path, method, operation) body_params extract_request_body(operation) properties {} properties.update(path_params[properties]) properties.update(body_params[properties]) required list(set(path_params[required] body_params[required])) # 完全没有任何参数时也要给一个空的 parameters 对象 if not properties: properties {_placeholder: {type: string, description: 无参数}} return { type: function, function: { name: function_name, description: description, parameters: { type: object, properties: properties, required: required, }, }, } def generate_tools(openapi_doc: Dict[str, Any]) - List[Dict[str, Any]]: 遍历 OpenAPI 文档的所有路径和方法生成工具集合。 tools [] paths openapi_doc.get(paths, {}) for path, path_item in paths.items(): # 常见的 HTTP 方法都覆盖到可以根据需要扩展 for method in [get, post, put, patch, delete]: operation path_item.get(method) if not operation: continue tool build_tool_definition(path, method, operation) if tool: tools.append(tool) return tools if __name__ __main__: doc load_openapi_document(OPENAPI_URL) tools_list generate_tools(doc) print(f共生成 {len(tools_list)} 个工具) # 输出到 output/tools.json with open(output/tools.json, w, encodingutf-8) as f: json.dump(tools_list, f, ensure_asciiFalse, indent2) # 顺便打印前三条快速确认生成效果 for tool in tools_list[:3]: print(json.dumps(tool, ensure_asciiFalse, indent2))这段代码里有两个设计值得说。第一请求体参数采取“平铺”策略。如果一个接口的请求体是{user: {name: xxx, age: 18}}我并没有把user作为一个嵌套对象而是把user.name和user.age平铺成两个参数。原因是我实测发现DeepSeek 在生成嵌套参数时经常会出现“把本该是对象的字段写成了字符串”或者“子字段名称拼错”的情况。平铺之后模型只需要生成两个平级的键值出错率低很多。代价是参数数量会变多但 50 个接口的量级完全可控。第二函数名清洗规则。DeepSeek 对function.name的校验很严格只允许a-zA-Z0-9_-字符且总长度不能超过 64。如果直接拿 Web 框架自动生成的 operationId比如GET /api/v1/orders/{id}格式化出的字符串很容易踩雷。清洗逻辑里把非法字符替换成下划线并且做了超长截断、数字开头补前缀的处理。3.5 验证生成的 Tools让模型自己试试生成之后别急着接业务先做一个快速验证。我一般会写一个简单的测试脚本随机挑 5~8 个工具构造一个用户问题让模型自己决定调用哪个工具并把参数提取结果打出来看。import requests import json # 读取生成的工具定义 with open(output/tools.json, r, encodingutf-8) as f: tools json.load(f) # 取前 5 个工具作为测试集 test_tools tools[:5] print(测试工具, [t[function][name] for t in test_tools]) payload { model: deepseek-chat, messages: [ {role: user, content: 帮我查一下订单DD20250101001的物流状态备注写上加急} ], tools: test_tools, tool_choice: auto, } resp requests.post( https://api.deepseek.com/chat/completions, headers{ Authorization: Bearer YOUR_API_KEY, Content-Type: application/json, }, jsonpayload, timeout30, ) resp_json resp.json() message resp_json[choices][0][message] print(模型回复, json.dumps(message, ensure_asciiFalse, indent2))如果一切正常返回结果里会有message.tool_calls其中包含模型选择的工具名和参数 JSON 字符串。这一步基本能验证两个关键点模型能不能准确理解工具描述、能不能把自然语言问题里的信息映射到正确的参数名上。如果连这一步都过不了后面就不用接了。4. 批量生成后的质量优化与性能调优4.1 50 个工具全部传给模型先想想到底要不要全传工具生成只是第一步真正接入业务时还有个问题绕不开每次请求该把多少工具传给模型。理论上DeepSeek 的上下文能容纳 50 个工具定义一个工具定义按 100~200 token 算50 个就是 5000~10000 token放进去不超限。但我实测下来工具过多时模型的选择准确率会明显下降。模型面对 50 个候选工具很容易选出“听起来像但实际不对”的那个。比如用户说“查一下最近订单”模型可能调用了“批量导出订单报表”而不是“查询订单列表”。我的做法是分组 按需加载。把 50 个工具按业务域分成 4~5 组订单、商品、用户、支付、报表在请求前先用一个粗略的规则或上一轮对话意图判断属于哪个域只把该组 8~12 个工具传给模型。这样既能保证覆盖面又能显著提高选择准确率。这个“先路由再选工具”的思路本质上是把一个大决策拆成两个小决策模型在每个小决策上的表现都会好很多。4.2 减少 token 占用的三个技巧精简描述、压缩枚举、限制嵌套生成的工具定义如果直接使用描述信息往往有些冗余。我发现三个能有效减少 token 占用、同时不影响模型理解的做法。第一description 控制在 50 个字以内。OpenAPI 文档里的描述通常包含完整句子但模型理解一个工具并不需要读小说。保留“接口做什么 关键约束”即可。比如原来是“根据订单ID查询订单详细信息包括商品列表、金额、状态、收货地址等订单ID格式为DD加日期加三位序号”精简后变成“按订单ID查询订单详情含商品、金额、状态”。第二枚举值截断。有些参数文档里列了 20 个枚举值实际上模型只要知道大致的选项范围和默认值就够了。我会写成生成脚本时对枚举超过 10 项的 schema只保留前 8 项并追加“等”字。第三嵌套层数限制在 3 层以内。虽然前面已经把请求体平铺了但某些 schema 本身就有嵌套对象属性。对于这些嵌套属性我会把整个子对象收敛成一个字符串类型参数描述里说明“支持 JSON 格式的 xx 数据”。模型生成一个 JSON 字符串比生成嵌套字典要稳定得多。4.3 增量更新与缓存策略API 变了怎么办接口总是要变的但重新生成 50 个工具再全量顶上去没必要也不安全。我的做法是给工具集加一个基于内容哈希的缓存机制。每次生成前计算 OpenAPI JSON 文档的 MD5。如果和上次一致直接用缓存的 tools.json不重复转换。如果变了用差异算法对比新旧工具集找出新增、修改、删除的工具名新增工具直接追加到工具集上线即可用。修改工具替换对应定义并检查是否有正在运行的会话引用了旧定义。删除工具先在代码层禁止调用等所有活跃会话结束后再清理。这套流程我是用 GitHub Actions 跑定时任务的每天凌晨拉一次最新的 OpenAPI 文档自动生成并提交变更。人工只需要 review 变更记录。这么设计的好处是团队里没有谁需要专门维护 Tools 定义文件了它完全是接口契约的“派生品”。5. 常见问题与排查技巧实录5.1 工具名非法字符导致 400 报错现象调用 DeepSeek API 时返回invalid tool name错误完全不知道错在哪。原因OpenAPI 文档的operationId常常包含点号、斜杠、空格或者开头是数字。直接照抄进function.name必然触发 API 校验。解决按上面代码里的sanitize_function_name做清洗。我遇到过最离谱的一个operationId是GET /api/v1/orders/{id}——不仅带了斜杠还有空格和花括号清洗之后才变成GET_api_v1_orders_id。虽然名字不好看但起码合法可用。5.2$ref没解析导致参数全丢现象生成出来的工具定义里每个参数的类型都是string描述都是空的。原因OpenAPI 文档里很多 schema 定义在components里用$ref引用。如果不解析引用parameters[*].schema取到的只是一个引用指针里面的type和description都是空的。解决用jsonref.replace_refs()在加载后统一解析。这一步必须做而且要在遍历paths之前做否则每个操作都要重复解析性能差而且容易漏。5.3 请求体只传了一个 JSON 字符串现象模型在调用有请求体的工具时生成的结果是request_body: {\name\: \xxx\}但接口实际需要的是{name: xxx}本身。原因生成工具定义时把请求体 schema 根对象平铺了但description里没有说清楚。模型不知道应该展开成多个参数。解决在请求体相关参数的description里显式写明“该参数是请求体的 JSON 字符串需要反序列化”。或者在 API 调用层做一个兼容处理检测到工具名是请求体类工具时先把request_body字符串解析成字典再转发给后端。5.4 多轮对话中 tools 的传递方式现象Agent 在第一轮正确调用工具拿到结果后第二轮模型突然不认这个工具了或者把上一轮的参数又原样返回一遍。原因很多人把tools定义只放在第一轮请求里后续轮次不传。但 DeepSeek 的对话是完整的上下文体模型在第二轮面对的是没有工具定义的“失忆”状态。解决每一轮请求都携带同样的tools定义。不要自作聪明地只在第一轮传。实测下来tools 定义对 token 的占用是固定开销换来的是每一轮模型都清楚自己还能调用哪些能力值得。5.5 排查工具调用返回不存在的函数名现象模型在tool_calls里返回了一个不在工具集里的函数名代码层直接 KeyError。原因一般是上一轮请求和这一轮请求之间工具集发生了变更比如增量更新删掉了一个工具但对话上下文里还残存着旧工具的记忆。解决在调用层做一道白名单校验。读到的function.name不在当前工具集时返回一条友好错误给模型让它换一个工具重试。这比直接抛异常要优雅得多。我再放一张问题速查表方便以后遇到类似问题时快速对照问题现象可能原因快速解法API 报 invalid tool nameoperationId 含非法字符用正则清洗限制字符集和长度参数类型全部变成 string$ref未解析加载后执行jsonref.replace_refs必填参数缺失required信息提取不完整分别提取 path/query/body 三处 required工具名互相覆盖不同路径生成同名函数追加 method 前缀或路径摘要模型频繁选错工具工具太多模型难以抉择按业务域分组按需只传 top-k 个嵌套参数模型总是填错嵌套层级太深平铺参数或用 JSON 字符串代替我个人的一些体会是工具定义的自动生成解决的是“维护效率”和“一致性”问题但真正让 Agent 好用的还是对每个工具的刻画——描述是否精准、参数是否必要、边界是否清晰。OpenAPI 文档通常包含这些信息但不会用模型的思维去组织。所以我的生成脚本里永远保留两个后门一个是全局的手工覆盖表可以对个别工具的description做定制一个是生成后的二次加工接口方便在工具集上统一追加更生动的描述。生成是骨架打磨才是灵魂。这套方案用到现在我最大的感觉是“终于不用再当翻译官了”。以前后端改接口我要跟着改 Tools现在改了接口重新跑一遍脚本就行。如果你也正在为几十个 REST API 的手写工具定义头疼不妨把它的 OpenAPI 文档找出来用这篇文章里的脚本试一遍——你会发现之前觉得最没技术含量又最耗时的活其实早就可以交给自动化了。