Jean's Blog

一个专注软件测试开发技术的个人博客

0%

Agent基础:结构化输出与工具调用

本文是 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_validator


class PatchPlan(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)

action: Literal["edit", "ask_user", "finish"]
file_path: str | None # 字段必填,但值可以为 null
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 os

from dotenv import load_dotenv
from openai import OpenAI

load_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 Path


def 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 json
import os
from typing import Literal

from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, ConfigDict

load_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 现在的状态是什么?"},
]

# 最多请求模型 3 次;每次最多处理 1 个工具调用。
# 这是教学限制,不表示模型协议本身只支持一个工具调用。
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 三条必须保留的边界

  1. 模型只能请求允许的工具:不要 eval 模型返回的字符串,也不要把任意函数名映射到全局函数。
  2. 模型给出的参数必须校验:schema 合法之外,还要检查用户权限和业务资源范围。
  3. 工具结果仍是不可信数据:网页、日志或文件中出现“忽略规则”不能让应用提高它的指令优先级。

工具失败时,可以回传结构化错误让模型调整请求;但要限制循环和修复次数。生产环境还需要独立的工具超时、总任务时限和副作用去重,不能只限制模型调用次数。

5. 本篇小结

能解析,不等于正确;结构正确,不等于安全;模型提出,不等于应用授权。

接下来把请求异常、拒答、截断和结构错误分开处理:错误处理与模型适配器。

参考资料

  1. OpenAI:Function calling
  2. OpenAI:Structured model outputs
  3. DeepSeek:JSON Output
  4. Pydantic:Validators
  5. DeepSeek:Using the Responses API
  6. Python:pathlib