让模型查询业务数据时,最重要的不是让它“记住”订单,而是把查询动作交给一个边界清晰的 Python 工具。本文把前面学过的 Tool Calling、结构化参数、历史记录和本地校验组合起来,做一个只读的订单查询助手:模型负责理解问题,程序负责查数据,最后由模型把工具结果解释给用户。

先划定模型和程序的职责

这个小项目只允许查询订单,不允许修改订单、退款或猜测不存在的数据。一次请求的路径如下:

  1. 用户输入自然语言问题。
  2. 模型判断是否需要调用 lookup_order。
  3. 程序读取工具参数,执行本地查询。
  4. 程序把查询结果作为 function_call_output 回传。
  5. 模型根据真实结果生成简短回答。

模型不能直接访问 Python 变量。工具定义是它能够使用的“接口说明”,真正执行工具的仍然是我们的程序。这种分工可以审计:日志中既能看到模型请求了什么,也能看到程序实际返回了什么。

准备环境和演示数据

示例使用 OpenAI Python SDK 的 Responses API。先创建虚拟环境并安装 SDK:

1
2
3
python -m venv .venv
source .venv/bin/activate
pip install openai

密钥和模型名称只从环境变量读取,不要写进代码或提交到 Git:

1
2
export OPENAI_API_KEY="你的 API 密钥"
export OPENAI_MODEL="你的模型名称"

为了让示例可以独立运行,先用内存字典模拟订单库。真实项目中可以把 lookup_order 换成数据库查询,但不要把数据库连接、写操作或任意 SQL 交给模型。

编写最小可运行程序

把下面代码保存为 order_assistant.py:

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import json
import os
import sys
from openai import OpenAI

ORDERS = {
"A1001": {"status": "已发货", "amount": 129.0, "currency": "CNY"},
"A1002": {"status": "待支付", "amount": 59.0, "currency": "CNY"},
}

TOOLS = [
{
"type": "function",
"name": "lookup_order",
"description": "只读查询演示订单。找不到订单时必须返回 found=false,不得猜测。",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号,例如 A1001",
}
},
"required": ["order_id"],
"additionalProperties": False,
},
"strict": True,
}
]

INSTRUCTIONS = """你是订单查询助手。
只能通过 lookup_order 查询订单,不能编造订单状态或金额。
如果工具返回 found=false,明确告诉用户没有查到,不要猜测。
回答使用中文,并说明这是查询结果。"""


def lookup_order(order_id: str) -> dict:
normalized_id = order_id.strip().upper()
order = ORDERS.get(normalized_id)
if order is None:
return {"found": False, "order_id": normalized_id}
return {"found": True, "order_id": normalized_id, "order": order}


def main() -> None:
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL")
if not api_key or not model:
raise SystemExit("请先设置 OPENAI_API_KEY 和 OPENAI_MODEL")

question = " ".join(sys.argv[1:]).strip()
if not question:
raise SystemExit("用法:python order_assistant.py 查询订单 A1001 的状态")

client = OpenAI(api_key=api_key)
input_items = [{"role": "user", "content": question}]
response = client.responses.create(
model=model,
instructions=INSTRUCTIONS,
tools=TOOLS,
tool_choice="auto",
parallel_tool_calls=False,
input=input_items,
)

# 保留模型发出的 function_call,下一次请求需要完整的对话轨迹。
input_items.extend(response.output)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
print(response.output_text)
return

for call in calls:
if call.name != "lookup_order":
raise RuntimeError(f"未允许的工具:{call.name}")
arguments = json.loads(call.arguments)
result = lookup_order(arguments["order_id"])
input_items.append(
{
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(result, ensure_ascii=False),
}
)

final_response = client.responses.create(
model=model,
instructions=INSTRUCTIONS,
tools=TOOLS,
input=input_items,
)
print(final_response.output_text)


if __name__ == "__main__":
main()

执行方式如下:

1
python order_assistant.py "查询订单 A1001 的状态和金额"

代码中有三个关键点。第一,工具参数使用 JSON Schema 描述,strict 和 additionalProperties 让参数边界更明确。第二,工具调用只是一条模型输出,SDK 不会替我们执行 lookup_order,必须由程序解析参数并调用函数。第三,回传结果时要带上原来的 call_id,这样模型才能把结果对应到刚才的请求。

还要注意,工具定义和工具实现是两回事。TOOLS 只是告诉模型“可以请求什么”,真正的安全边界在 lookup_order 内部:它只接受订单编号,只读取固定数据,并且对未知编号返回明确的 found=false。即使模型生成了一个看似合理的订单号,程序也不会把它当成真实订单。

真实项目可以把一次调用记录成一条审计事件,例如保存请求时间、用户标识、原始问题、工具名、规范化后的订单编号、工具结果摘要和最终响应。不要把完整密钥或不必要的个人信息写入日志。这样出现“模型说查到了,但数据库没有记录”的问题时,可以沿着调用链定位,而不是只依赖聊天窗口里的文字。

如果需要接入数据库,建议保持函数签名不变,把数据访问封装在函数内部,并给查询设置超时和最大返回字段。模型只应该看到完成任务所需的最少信息,例如状态和金额,不应获得整行订单记录。先让工具返回稳定的小 JSON,再考虑分页、权限和缓存,调试成本会更低。

常见问题

为什么模型有时不调用工具? 用户问题可能不需要订单查询,或者模型没有从问题中识别出订单编号。不要为了“保证调用”而把所有问题都强制路由到工具;程序还应在执行前检查订单编号格式和权限。

工具返回 JSON 后,为什么还要再次请求模型? JSON 是程序能读懂的结果,用户通常需要的是自然语言说明。第二次请求让模型基于工具的真实结果组织回答,而不是凭记忆回答。

只读工具真的安全吗? 只读能减少副作用,但不等于没有风险。真实项目仍需在工具函数内部做用户身份校验、字段脱敏、访问日志和超时控制。模型提示词只能辅助约束,不能替代程序权限。

为什么示例只执行一轮工具调用? 这是为了保持最小实现。生产代码应使用带上限的循环,处理模型连续请求多个工具的情况,并在达到最大步数、参数校验失败或工具异常时停止,避免无限调用和费用失控。

小结

Tool Calling 的核心不是让模型“拥有数据库权限”,而是把可调用能力收缩成明确的函数协议。模型负责选择和解释,Python 负责校验、执行与记录。先从一个只读工具开始,再逐步加入身份校验、错误处理和审计日志,通常比直接构建能够修改数据的 Agent 更容易验证和维护。