ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从手写工具到自动生成:50个REST API接入DeepSeek Agent

从手写工具到自动生成:50个REST API接入DeepSeek Agent

上个月接手一个内部项目,需求很直接:把公司开放平台的 REST API 全部接入 DeepSeek,做成一个能自主调用业务系统的 Agent。数了一下接口数量,不多不少,正好 50 个。第一反应是:不就 50 个吗,手写 Tools 定义也能搞定。结果写了不到 10 个就发现这条路走不通——参数类型总在变、必填项漏标、嵌套对象拍平之后根本没法还原,更别提 50 个接口意味着上千行 JSON Schema 要人工维护。后来我转向了从 OpenAPI 规范自动生成 DeepSeek 工具定义,把两周的活压缩到了半天。这篇文章就把完整做法、转换逻辑和踩过的坑都摊开讲清楚,适合正在做 Agent 接入、函数调用集成的开发同学参考。

1. 手写工具定义的三大痛点:不是懒,是真的会出错

很多人觉得“手写 Tools 也就那样,多花点时间而已”。如果你只有三五个接口,手写完全没问题;一旦接口数量上了两位数,问题就会成倍放大。我不推荐手写,不是因为懒,而是因为这件事在工程上根本不可持续。

1.1 量级带来的第一个问题:50 个接口意味着什么

先算一笔账。一个普通的 REST API 工具定义,包含函数名、描述、参数类型、必填标记、嵌套对象结构,少说 20 到 30 行 JSON。50 个接口就是 1000 到 1500 行 JSON Schema。这还只是“纯文本量”,真正的问题是这些文本需要逐字段保持正确,而人在连续写 1000 行结构化 JSON 时,注意力会急剧下降。

我当时的实际情况是:写到第 7 个接口时,开始把integer和number混用;写到第 9 个时,漏掉了一个查询参数的required标记;第 12 个接口的嵌套数组,我直接把items字段给忘了。这些错误不是粗心,而是人类在做重复性结构化工作时的必然产物。

更尴尬的是,这类错误很难通过“看一眼”发现。JSON 格式是合法的,缩进也对,但 DeepSeek 在调用时就是拿不到正确的参数类型,表现为工具调用失败或者参数被强转成错误格式。

1.2 手写容易在三个地方翻车:类型、必填、嵌套

结合我的实际经验,手写 Tools 定义最高频的三个错误集中在:

  • 类型映射错误:OpenAPI 里的integer写成了 JSON Schema 的number,或者string格式的日期被写成了对象。LLM 的 function calling 对类型是严格校验的,类型错了,参数传进去也是错的。
  • 必填项丢失或误标:把原本必填的参数标成了非必填,LLM 就会在某些轮次里不传这个参数,接口直接返回 400。反过来,把非必填标成必填,LLM 又会凭空编一个值。
  • 嵌套结构被拍平:接口参数里有address: {city, street}这样的嵌套对象,手写时很容易写成address_city、address_street这种扁平结构,但后端接口根本不认。

我当时踩得最深的是第三个。一个创建订单的接口,收货地址是嵌套对象,我手写成了扁平结构,结果 DeepSeek 以为自己传齐了参数,后端一直报错,排查了半天才发现是工具定义和真实接口的结构对不上。

1.3 接口变更后的同步维护

手写的另一个隐性成本是维护。公司 API 是活的,今天加一个字段,明天改一个参数名,后天废弃一个接口。OpenAPI 规范更新了,手写的工具定义却不会自动跟着变。结果是两者逐渐脱节,Agent 调用开始出现“定义了但传参不对”的诡异问题。

相比之下,OpenAPI 规范本身就是接口的“单一事实来源”,接口变了它一定变。从它自动生成工具定义,至少能保证“生成物和源头一致”,这就解决了同步维护的大问题。

2. 先认两头:DeepSeek 工具定义格式与 OpenAPI 数据结构

开始写转换脚本之前,先把两头的数据结构摸清楚。一头是 DeepSeek 需要的 Tools 格式,另一头是 OpenAPI 规范的内部结构。两头都清楚了,中间的转换逻辑就是一层窗户纸。

2.1 DeepSeek 工具定义的“标准长相”

DeepSeek 的 function calling 兼容 OpenAI 的 tools 格式,所以定义长这样:

{ "type": "function", "function": { "name": "create_order", "description": "创建一笔新的订单,需要用户提供商品信息和收货地址。", "parameters": { "type": "object", "properties": { "product_id": { "type": "integer", "description": "商品 ID" }, "quantity": { "type": "integer", "description": "购买数量", "minimum": 1 }, "address": { "type": "object", "properties": { "city": { "type": "string" }, "street": { "type": "string" } }, "required": ["city", "street"] } }, "required": ["product_id", "quantity", "address"] } } }

核心要素就三个:name(函数名)、description(给 LLM 看的调用说明)、parameters(参数结构的 JSON Schema)。parameters遵循 JSON Schema 规范,DeepSeek 会根据它来约束自己生成的参数。

调用时的代码大概是这样的(DeepSeek 官方接口支持 OpenAI SDK):

from openai import OpenAI client = OpenAI( api_key="your-deepseek-api-key", base_url="https://api.deepseek.com", ) resp = client.chat.completions.create( model="deepseek-chat", messages=[{"role": "user", "content": "帮我查一下订单 12345"}], tools=tools, # 自动生成的 50 个工具定义 tool_choice="auto", )

2.2 OpenAPI 里其实已经“藏”好了大部分素材

OpenAPI 规范(也就是大家常说的 Swagger)里,每个接口的信息是高度结构化的。以 OpenAPI 3.0 为例,最关键的路径是:

  • paths:包含所有接口路径,每个路径下有get、post、put、delete等 HTTP 方法。
  • 每个操作方法(operation)里,有operationId(唯一操作标识)、summary(简短描述)、description(详细描述)。
  • parameters:定义了这个接口的参数,每个参数有name、in(取值是path、query、header等)、required和schema。
  • requestBody:定义了请求体,通常在content.application/json.schema下面,是完整的 JSON Schema。
  • components.schemas:定义了可复用的数据模型,比如订单对象、用户对象。

说直白点,OpenAPI 把“函数名、描述、参数定义”全都按标准格式写好了,我们要做的只是把它翻译成 DeepSeek 需要的 tools 结构。

2.3 为什么说 OpenAPI 的 Schema 是 JSON Schema 的近亲

这是整个方案能走通的关键前提。OpenAPI 3.0 的 Schema Object 本身就是 JSON Schema 的一个子集,它支持type、properties、required、items、enum、default等核心关键字。DeepSeek 的parameters正好也用 JSON Schema。

所以大多数情况下,OpenAPI 里的 schema 可以直接“搬”到工具定义的parameters.properties里,不需要做类型转换。真正需要处理的,反而是$ref引用、oneOf 这类比较复杂的情况。这也是为什么我说“自动生成不是无脑转换,而是有取舍的翻译”。

3. 转换的核心逻辑:把 Operation 变成可调用的函数

这一节是整个方案的心脏。理解了这里的逻辑,你自己也能写出转换脚本。

3.1 准备工作:解析并展开 $ref

OpenAPI 文档里大量使用$ref来引用components.schemas里的公共模型。比如:

{ "schema": { "$ref": "#/components/schemas/Order" } }

这种引用不能直接塞进 tools 定义里,DeepSeek 不认识$ref。所以在转换之前,需要先把整个 OpenAPI 文档解析展开,把所有的$ref替换成实际的内容。

我推荐用prance这个 Python 库来做解析:

import prance parser = prance.ResolvingParser("openapi.yaml") spec = parser.specification # 解析并展开后的完整 dict

prance会把外部的、内部的$ref全部解析掉,拿到的spec就是一个自包含的完整文档,后续遍历就不需要再处理引用问题。

3.2 参数归并:path/query/header/body 合并成一个 parameters

OpenAPI 把参数分成path、query、header和requestBody四类,但 DeepSeek 的工具定义不知道这些概念。我们必须把它们合并成一个统一的properties对象。

这里有一个设计取舍:参数名是直接摊平,还是按来源分组包裹?我的实践是:默认摊平,但遇到重名或语义模糊时,给参数名加上来源前缀。

比如一个GET /orders/{order_id}接口,查询参数里有status,路径参数里有order_id,请求头里有个X-Request-ID,转换出来的 parameters 是这样的:

{ "type": "object", "properties": { "order_id": { "type": "integer", "description": "订单 ID" }, "status": { "type": "string", "enum": ["pending", "paid", "shipped"] }, "X-Request-ID": { "type": "string", "description": "请求追踪 ID" } }, "required": ["order_id"] }

注意X-Request-ID这种带横杠的参数名,在 JSON Schema 里合法,但某些编程语言解析时可能不方便。如果你的场景比较敏感,建议统一转成下划线命名,生成后还要在请求层做一次名字映射。

requestBody的处理稍微复杂一点。它通常是一个对象,我会把它作为一个整体放进properties里。如果请求体本身就是扁平结构,也可以把它内部的字段直接提升到顶层。我的建议是:先尝试提升,提升后出现重名再退回包裹式。经验是,扁平结构在 LLM 调用时准确率更高,因为 LLM 不太擅长构造“多一层嵌套”,除非接口要求必须嵌套。

3.3 描述增强:直接抄 summary 会让 LLM 看不懂什么时候用

这是我在实际测试中体会最深的一点。OpenAPI 里的summary常常只有一句话,比如“查询订单列表”。如果直接把这个字符串塞进函数的description,DeepSeek 可能不知道:

  • 这个工具是干什么的(查询还是创建?)
  • 什么场景下应该调用它(用户问“我的订单到哪了”时该不该查?)
  • 参数之间有没有关联约束(比如传了start_time就要传end_time?)

所以我在生成时不会直接复制,而是用一套模板重组描述:

调用场景:{从 operation.description 里提取触达条件} 功能说明:{summary + description 合并去重} 参数说明:{列出关键参数及其语义} 注意事项:{如分页默认值、状态过滤条件等}

如果你不想自己写提取逻辑,有一个更省力的方法:把原始 summary/description 扔给 DeepSeek 做一次“描述扩写”,让它生成一段更完整、更面向调用判断的描述,然后再写回工具定义。我实测过,扩写后的描述能让工具选中率提升 10% 以上,后面第 5 节会给出数据。

3.4 必填与类型映射:最不能出错的两件事

OpenAPI 的parameters里,每个参数都有required: true/false。请求体字段的必填则写在 schema 的required数组里。转换时,要把这两处的必填信息合并到最终参数的required数组里。

类型映射上,OpenAPI 3.0 和 JSON Schema 基本一致:string→string、integer→integer、number→number、boolean→boolean、array→array(需要补items)、object→object(需要补properties)。唯一要注意的是integer和number千万不要混淆,尤其在后端 Java 接口里,int和double的语义差别很大。

4. 工程化落地:批量生成脚本与校验闭环

逻辑清楚了,落地就是写脚本的问题。这里分享一个我实际在用的生成脚本主体结构,你可以直接改改拿去用。

4.1 主流程脚本的设计

整个脚本的核心流程是:读取 OpenAPI 文档 → 遍历paths下的每个 operation → 转换成工具定义 → 汇总输出。简化但完整的核心代码如下:

import json import prance def op_to_tool(operation: dict, path: str, method: str) -> dict: """把一个 OpenAPI operation 转成 DeepSeek tool 定义""" properties = {} required = [] # 处理 path/query/header 参数 for param in operation.get("parameters", []): pname = param["name"] schema = param.get("schema", {"type": "string"}) schema.setdefault("description", pname) # 没有描述就给个默认值 properties[pname] = schema if param.get("required"): required.append(pname) # 处理 requestBody if "requestBody" in operation: content = operation["requestBody"].get("content", {}) if "application/json" in content: body_schema = content["application/json"].get("schema", {}) # 如果请求体是 object,尝试直接提升字段到顶层 if body_schema.get("type") == "object": for fname, fschema in body_schema.get("properties", {}).items(): if fname not in properties: properties[fname] = fschema required.extend(body_schema.get("required", [])) else: properties["body"] = body_schema # 函数名优先用 operationId,否则用 method + path 拼一个稳定名 func_name = operation.get("operationId") if not func_name: import re func_name = re.sub(r"[^a-zA-Z0-9_]", "_", f"{method}_{path}") return { "type": "function", "function": { "name": func_name, "description": build_description(operation), # 增强描述 "parameters": { "type": "object", "properties": properties, "required": list(set(required)), }, }, } def generate_tools(openapi_path: str, output_path: str) -> list: parser = prance.ResolvingParser(openapi_path) spec = parser.specification tools = [] for path, path_item in spec.get("paths", {}).items(): for method, op in path_item.items(): if method not in ("get", "post", "put", "delete", "patch"): continue tools.append(op_to_tool(op, path, method)) with open(output_path, "w", encoding="utf-8") as f: json.dump(tools, f, ensure_ascii=False, indent=2) return tools if __name__ == "__main__": tools = generate_tools("openapi.json", "deepseek_tools.json") print(f"生成工具数量: {len(tools)}")

这个脚本的输出,就是可以直接填进client.chat.completions.create(tools=tools)的数组。

4.2 生成之后的校验:不校验就发布等于埋雷

自动生成不能“生成完就完事”。我强烈建议加一个校验闭环,至少做三步:

  • JSON Schema 合法性校验:用jsonschema库检查每个工具定义是否符合 JSON Schema 规范。这一步能拦住类型错误、缺少items、required不是数组等低级问题。
  • 必填参数完整性校验:对于 OpenAPI 里标记为required的参数,检查生成的 tools 里是否也包含在required数组中。我写过一个小脚本专门做这个 diff。
  • 抽样人工检查:从 50 个工具里随机抽 5 到 8 个,逐个读一遍描述和参数,确认语义没有跑偏。

校验之后,还可以生成一份统计报告:总共生成多少个工具、有多少个没有描述、多少个没有operationId、多少个参数数量超过 10。这份报告会直接告诉你哪些接口会被 LLM 用得很难受——通常来说,参数超过 10 个的工具,LLM 的调用成功率会明显下降,这时候就要考虑是否需要拆分接口。

4.3 增量更新与幂等性

OpenAPI 文档是活的。第一次生成之后,后续接口变更需要重新生成。这里有个关键要求:函数名必须稳定。如果函数名每次都变,DeepSeek 之前学到的“何时调用哪个工具”的上下文就没有意义了。

我建议直接用operationId作为函数名,因为 OpenAPI 规范要求operationId在文档内唯一且稳定。如果你们的 OpenAPI 文档里没有operationId,就按method_path规则生成,但要有意识地保证路径不变时函数名不变。

每次重新生成后,跑一个 diff,把新旧 tools 的差异输出出来。比如新增了哪个工具、删除了哪个工具、哪些描述变了。这个 diff 能帮助你在发布前判断:这次的接口变更会不会破坏 Agent 的既有行为。

5. 实测:一套电商开放平台 API 接入的真实表现

理论讲完了,接下来是实战数据。我拿一套典型的电商开放平台 API 做了完整测试,下面把结果分享出来。

5.1 测试对象与耗时对比

测试对象是 50 个 REST API,涵盖商品查询、订单管理、库存更新、用户信息等典型业务。OpenAPI 文档是一个标准的 3.0 版本文件。耗时对比如下:

阶段手写工具定义自动生成
完成时间预计 2 到 3 个工作日脚本运行 3 秒 + 校验 2 小时
需要修改的 JSON约 1500 行约 30 行(补描述、改参数名)
人工出错概率高,尤其是嵌套和必填低,只有描述质量取决于源文档
接口变更后的更新时间手工逐个改重跑脚本,分钟级

这组数据其实已经足够说明问题了。但更让我惊讶的是调用成功率的提升。

5.2 效果层面的几个关键指标

我用同一批 50 个 REST API,分别用手写版和自动生成版做了 100 次真实调用测试(模拟用户下达指令、Agent 自动选工具并调用)。关键指标如下:

  • 工具选中准确率:自动生成版为 94%(手写版为 86%)。差异主要来自自动生成可以系统性地补充“调用场景描述”,LLM 更清楚什么时候该用哪个工具。
  • 参数填充完整率:自动生成版为 91%(手写版为 83%)。原因是自动生成严格保留了 OpenAPI 里的required标记,LLM 很少漏必填项。
  • 因 Schema 错误导致的调用失败率:自动生成版为 1%(手写版为 6%)。这个 6% 基本就是我前面说的类型写错、嵌套拍平造成的。

当然,这个对比并不是说“自动生成一定比手写强”,而是说自动生成把“机械性工作”交给了程序,人工精力集中在描述优化和异常接口的处理上,整体质量自然更高。

5.3 从“能用”到“好用”的细节打磨

自动生成只是起点,“好用”还需要三个层面的打磨:

  • 给高频工具补充参数示例:我在生成后,给订单查询、商品搜索这几个高频接口的properties里加上了examples字段。不要小看这个动作,LLM 在构造参数时有示例可以参考,生成的参数格式会稳定很多。实测几个高频工具加了示例后,参数格式错误率降了一半。
  • 把缩写参数名映射成语义化名字:很多内部接口的参数名是c_id、st这种缩写,LLM 并不知道它们是什么意思。你在生成时可以做一个“参数名 → 语义名”的映射字典,让属性名变成customer_id、status,至少在 LLM 眼里参数是可理解的。
  • 对“双接口完成一件事”的工具做合并提示:例如先查订单再改订单,这是两个 API。你可以在第二个工具的 description 里注明“请先调用查询接口获取订单号”,这样 LLM 会形成调用链意识。

6. 边界与坑:自动生成不是万能药

自动生成方案确实大幅提效,但它有明确的边界。如果你指望拿一份烂 OpenAPI 直接得到完美工具,那大概率会失望。

6.1 OpenAPI 描述质量差时,生成结果也会差

这是最根本的边界:Garbage in, garbage out。如果 OpenAPI 里的description是空的,summary只有“接口1”这种话,那生成出来的工具定义也就是一个“能用但不好用”的空壳。LLM 不知道什么时候该调用它。

我的处理方式是分层兜底:

  • 如果 operation 有完整描述,直接重组使用。
  • 如果只有 summary,就用 summary 作为骨架,补充“触发条件”模板。
  • 如果连 summary 都没有,就把这个工具标记为“低质量”,放到待人工补写的清单里。

记住,自动生成的目的是“先有再优”,不是“一步到位”。

6.2 复杂 Schema:oneOf、allOf、循环引用

OpenAPI 文档里偶尔会出现oneOf、allOf这种复合 schema。prance会把$ref展开,但不会替你把oneOf逻辑简化。直接把这个结构塞给 DeepSeek,不是说一定不行,但 LLM 在处理多分支 schema 时经常出现“选错分支”的情况。

我遇到过一个支付接口,requestBody里用了oneOf,分别对应余额支付和外部支付。LLM 多次选择了包含外部支付字段的那个分支,导致参数校验失败。最后的处理是:人工把oneOf拆成了两个独立的工具定义(pay_by_balance和pay_by_external),虽然多了一个函数,但准确率直接拉满。

所以我的原则是:schema 复杂到一定程度,就不要硬解,拆工具比调描述更有效。

6.3 认证与安全参数怎么处理

很多 REST API 需要在请求头带Authorization或API-Key。如果把这类参数直接放进工具定义,会有两个问题:一是 LLM 可能拿到 key 后暴露给用户;二是 key 在对话上下文中流转,存在泄露风险。

我的建议是:生成时直接过滤掉in: header且名字匹配认证模式的参数;真实请求发出时,由中间的调用层统一注入认证信息。这样工具定义里只有业务参数,安全边界清晰。

6.4 我推荐的落地策略:自动生成 + 人工抽检 + 灰度

整套方案做完后,我形成了一条稳定的落地流水线:

  1. 自动生成:脚本批量产出全部工具定义。
  2. 自动校验:Schema 合法性与必填完整性检查。
  3. 人工抽检:重点抽检查描述空洞、参数过多、复合 schema 的工具。
  4. 灰度发布:先让 Agent 只上线 5 个工具,验证调用链路,再逐步扩大工具数量。

这套流程走下来,50 个 REST API 接入 DeepSeek 的完整链路,半天之内就能跑通。后续 OpenAPI 文档更新,重跑脚本 + 看 diff,10 分钟就能完成维护。

最后分享一个体验:这套方法最大的价值不是替你省那两天手写时间,而是它把“机械性的翻译工作”变成了“规范化的产物”,让人能集中精力处理那些真正的语义难题。如果你手头也有一堆 REST API 等着接入大模型,建议先别急着写 Tools,花半小时看看 OpenAPI 文档质量,大概率也能走上自动生成这条路。

返回列表