本文是 LLM API 基础系列第三篇。上一篇:生成参数、Token 与上下文管理 ;下一篇:错误处理与模型适配器 。
接口行为核对于 2026-09-21 。Python 示例沿用第一篇依赖,Pydantic 使用 v2。
1. 两类契约,不要混为一谈
结构化回答 :模型按指定结构输出结果,供界面或程序消费。例如输出一个修改计划。
工具调用 :模型提出执行某个函数的请求,应用校验、执行并回传结果。
二者都可能使用 JSON Schema,但“输出一个 action=edit 的 JSON”并不等于文件已被修改;“返回函数名和参数”也不等于调用被授权。[1]
2. 从合法 JSON 到允许执行,中间还有什么 graph TD
A[模型响应] --> B{完整且未拒答}
B -->|否| X[停止或修复请求]
B -->|是| C[解析 JSON]
C --> D[校验字段和类型]
D --> E[校验跨字段业务规则]
E --> F{权限与资源范围允许}
F -->|否| X
F -->|是| G[执行或等待人工确认]
图中任意解析或校验失败都应进入停止/修复分支,不能继续执行。对于写文件、发消息、付款等有副作用的工具,还要考虑幂等、审计和人工确认。
2.1 三种机制的边界
机制
主要保证什么
不保证什么
JSON Mode
正常完成时输出可解析的 JSON
字段一定符合你的 schema、业务一定正确
原生 Structured Outputs
在支持的模型和 schema 范围内约束输出结构
事实正确、权限正确,或永远不拒答/不中断
本地校验与执行授权
应用定义的类型、业务及权限约束
自动证明所有外部事实真实
JSON Mode 和原生 Structured Outputs 通常是生成侧的两种选择 ,不是必须依次开启的两层。无论选择哪一种,都需要应用侧校验。[2]
注意:
JSON Mode 通常要求提示中明确请求 JSON;仍要处理空内容、截断和异常情况。[3]
Structured Outputs 只支持 JSON Schema 的一个子集,是否支持某种约束要查对应模型文档。
“Pydantic 能校验”不代表“生成的完整 schema 一定被服务端接受”。
不支持的 schema 可能直接导致请求报错;拒答可以通过专门字段返回;不能概括成“任何不匹配都会自动重试”。[2]
2.2 先定义本地契约 下面的 PatchPlan 是一个计划 ,不是执行许可。把这段代码与下一小节的调用代码放在同一个 Python 文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from typing import Literal from pydantic import BaseModel, ConfigDict, model_validatorclass PatchPlan (BaseModel ): model_config = ConfigDict(extra="forbid" , strict=True ) action: Literal ["edit" , "ask_user" , "finish" ] file_path: str | None reason: str risk: Literal ["low" , "medium" , "high" ] @model_validator(mode="after" ) def check_business_rules (self ) -> "PatchPlan" : if not 1 <= len (self .reason.strip()) <= 500 : raise ValueError("reason 去掉首尾空白后须为 1–500 字符" ) if self .action == "edit" and not (self .file_path or "" ).strip(): raise ValueError("edit 必须提供非空 file_path" ) if self .action != "edit" and self .file_path is not None : raise ValueError("非 edit 动作的 file_path 必须为 null" ) return self
这里刻意让生成侧 schema 保持简单,跨字段规则在本地校验。model_validator 中的任意 Python 逻辑不会自动变成服务端生成约束 。[4]
对普通文本返回,可以调用 PatchPlan.model_validate_json(model_text)。这一步既检查 JSON 结构,也触发本地业务规则;验证失败就不能执行。[4]
2.3 原生结构化输出示例:OpenAI Responses 以下示例连接 OpenAI 官方服务 ,与第一篇的 DeepSeek 示例分开。配置 OPENAI_API_KEY,并将 OPENAI_MODEL 设置为账户中支持 Responses 和 Structured Outputs 的模型。[2]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import osfrom dotenv import load_dotenvfrom openai import OpenAIload_dotenv() client = OpenAI( api_key=os.environ["OPENAI_API_KEY" ], base_url="https://api.openai.com/v1" , timeout=60.0 , max_retries=0 , ) response = client.responses.parse( model=os.environ["OPENAI_MODEL" ], instructions=( "只提出计划,不执行操作。" "edit 必须给出工作区内的相对路径;非 edit 时 file_path 为 null。" "reason 为 1–500 字符。risk 只是你的建议,不代表授权。" ), input ="先询问用户希望修改哪个文件,不要猜测路径。" , text_format=PatchPlan, max_output_tokens=2000 , ) if response.status != "completed" : raise RuntimeError(f"响应未完成:{response.incomplete_details} " ) if response.output_parsed is None : raise RuntimeError("未得到可用的 PatchPlan,请检查响应内容" ) plan = response.output_parsed print (plan.model_dump())
responses.parse() 可以帮助处理 Pydantic 类型,但网络错误、结构校验异常仍需上层处理。示例只打印计划,不执行文件修改。
不要仅因为另一家服务也有 responses.create(),就假设它支持完全相同的 text.format、严格 schema 或解析行为。[2][5]
3. 文件路径校验:不能只检查 startswith(“../“) 原文的检查会漏掉 a/../../secret、绝对路径和指向工作区外的符号链接。下面只演示:在受控、本地、可信工作区中,校验一个已存在的可编辑文件 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from pathlib import Pathdef resolve_edit_target ( workspace: Path, relative_path: str , allowed_files: set [Path] ) -> Path: root = workspace.resolve(strict=True ) requested = Path(relative_path) if not relative_path.strip() or requested.is_absolute(): raise ValueError("必须提供非空相对路径" ) target = (root / requested).resolve(strict=True ) if not target.is_relative_to(root): raise ValueError("解析后的路径越过工作区" ) if not target.is_file(): raise ValueError("只允许编辑已存在的普通文件" ) if target not in allowed_files: raise ValueError("文件不在应用批准的白名单中" ) return target
allowed_files 必须由应用事先构造为规范化的绝对路径集合,不能直接采用模型输出。Path.resolve() 会解析符号链接和 ..,但这仍然不是完整的文件沙箱 。[6]
校验与实际打开文件之间存在竞态(TOCTOU);恶意进程还可能替换符号链接,硬链接等情况也需要操作系统权限边界。生产工具应在受限进程/容器中执行,并按平台选用安全的文件打开策略。不要让入门示例冒充完整安全方案。
模型输出的 risk="low" 也不是可靠的权限判断。风险、工作区范围、允许动作及审批条件最终由应用策略控制。
4. 自定义工具调用的完整闭环 %%{init: {"sequence": {"width": 100, "actorMargin": 35}}}%%
sequenceDiagram
participant A as 应用
participant M as 模型服务
participant T as 本地工具
A->>M: 任务与工具定义
M-->>A: 工具请求与调用 ID
Note over A: 校验与授权
A->>T: 执行授权函数
T-->>A: 返回执行结果
A->>M: 回传配对结果
M-->>A: 回答或新工具请求
工具定义只是告诉模型“可以请求什么”。执行始终发生在应用这一侧。 OpenAI 的托管工具是另一种执行边界,不能和应用自定义函数混为一谈。[1]
4.1 可运行示例:查询演示服务状态 下面使用 DeepSeek Chat Completions,沿用第一篇的 .env。工具只读取一个固定字典,不访问真实服务,也没有写操作 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import jsonimport osfrom typing import Literal from dotenv import load_dotenvfrom openai import OpenAIfrom pydantic import BaseModel, ConfigDictload_dotenv() client = OpenAI( api_key=os.environ["DEEPSEEK_API_KEY" ], base_url=os.getenv("DEEPSEEK_BASE_URL" , "https://api.deepseek.com" ), timeout=30.0 , max_retries=0 , ) class StatusArgs (BaseModel ): model_config = ConfigDict(extra="forbid" , strict=True ) service: Literal ["demo-api" ] def get_service_status (args: StatusArgs ) -> dict : return {"service" : args.service, "status" : "healthy" , "source" : "demo" } tools = [{ "type" : "function" , "function" : { "name" : "get_service_status" , "description" : "查询 demo-api 的演示状态,数据不是实时监控结果。" , "parameters" : StatusArgs.model_json_schema(), }, }] messages = [ {"role" : "system" , "content" : "先调用工具查询,再解释结果;说明数据来自演示。" }, {"role" : "user" , "content" : "demo-api 现在的状态是什么?" }, ] for _ in range (3 ): response = client.chat.completions.create( model=os.environ["DEEPSEEK_MODEL_NAME" ], messages=messages, tools=tools, max_tokens=800 , extra_body={"thinking" : {"type" : "disabled" }}, ) if not response.choices: raise RuntimeError("空候选结果" ) choice = response.choices[0 ] message = choice.message if choice.finish_reason not in {"stop" , "tool_calls" } or message.refusal: raise RuntimeError(f"响应不可执行:{choice.finish_reason} " ) if message.tool_calls: if choice.finish_reason != "tool_calls" : raise RuntimeError("结束原因与工具请求不一致" ) if len (message.tool_calls) != 1 : raise RuntimeError("超过本示例的单轮工具调用预算" ) call = message.tool_calls[0 ] if call.type != "function" or call.function.name != "get_service_status" : raise ValueError("未授权的工具名称或类型" ) args = StatusArgs.model_validate_json(call.function.arguments) result = get_service_status(args) messages.append(message.model_dump(exclude_none=True )) messages.append({ "role" : "tool" , "tool_call_id" : call.id , "content" : json.dumps(result, ensure_ascii=False ), }) else : if choice.finish_reason != "stop" or not message.content: raise RuntimeError("没有完整的最终回答" ) print (message.content) break else : raise RuntimeError("达到模型调用轮次上限,停止执行" )
这次才出现真正的“循环”:模型可以先请求工具,再根据结果回答。它仍只是一个最小、受限的例子,不具备持久记忆、复杂规划或生产级恢复机制。
4.2 改成 Responses 时,改变的是协议映射
步骤
Chat Completions
Responses
提出工具请求
message.tool_calls[]
output[] 中的 function_call
函数名和参数
call.function.name / arguments
item.name / arguments
关联键
tool_call.id
function_call.call_id,不是输出项的 id
回传结果
role: tool + tool_call_id
type: function_call_output + call_id + output
应用仍然要做同样的授权与执行。OpenAI 手动维护 Responses 上下文时,应按文档保留相关输出项,包括需要回传的推理项;DeepSeek 的状态管理边界另见第一篇。[1][5]
4.3 三条必须保留的边界
模型只能请求允许的工具 :不要 eval 模型返回的字符串,也不要把任意函数名映射到全局函数。
模型给出的参数必须校验 :schema 合法之外,还要检查用户权限和业务资源范围。
工具结果仍是不可信数据 :网页、日志或文件中出现“忽略规则”不能让应用提高它的指令优先级。
工具失败时,可以回传结构化错误让模型调整请求;但要限制循环和修复次数。生产环境还需要独立的工具超时、总任务时限和副作用去重,不能只限制模型调用次数。
5. 本篇小结 能解析,不等于正确;结构正确,不等于安全;模型提出,不等于应用授权。
接下来把请求异常、拒答、截断和结构错误分开处理:错误处理与模型适配器 。
参考资料
OpenAI:Function calling
OpenAI:Structured model outputs
DeepSeek:JSON Output
Pydantic:Validators
DeepSeek:Using the Responses API
Python:pathlib