Appearance
Agent基本结构
Agent 这个词通常被用来描述一整套系统,但放到 Python 脚本里看,核心就是一个会反复跑的循环:模型看当前问题和已有结果,判断要不要调用工具;Python 执行工具,把结果放回输入里;模型继续判断,直到给出回答。
函数调用那篇只跑了一次工具:模型要查联系人,Python 查完以后交回去。Agent 多出来的地方,是它可能不止查一次。比如一个问题里既要看待办清单,又要展开某一条详情,模型就会先调用 list_todos,拿到列表后再决定要不要调用 get_todo。
这个循环看着简单,但里面有几个容易糊在一起的东西:历史记录放在哪里,工具怎么路由,模型什么时候算结束,工具一直被调用怎么办。这些不处理清楚,脚本很容易变成“模型想调什么就调什么,调到哪算哪”。
一、循环
普通模型调用是一次请求、一次回答。RAG 脚本也是固定流程:先检索,再生成。Agent 的味道不太一样,它不是在代码里写死每一步,而是让模型根据当前信息决定下一步动作。
比如问题是:
text
今天还有哪些没完成?把优先级最高的一项展开说明。Python 手里有两个工具:
list_todos:返回所有待办的标题、状态和优先级。get_todo:按todo_id返回某条待办的详细说明。
模型第一次看到问题时,不知道有哪些待办,于是会请求 list_todos。Python 返回列表后,模型看到 todo-002 优先级最高,可能继续请求 get_todo(todo_id="todo-002")。拿到详情以后,模型再组织最终回答。
我一开始以为 Agent 就是“多给模型几个工具”,后来才发现真正绕的地方在循环本身。工具只是入口,关键是每一轮模型输出什么、Python 执行什么、结果怎么放回去、什么时候停下来。
二、待办数据
先准备一个很小的待办文件。这个例子不碰日志、不碰告警,只看 Agent 的基本形状。
新增文件:todos.json
json
[
{
"id": "todo-001",
"title": "买菜",
"status": "done",
"priority": "low",
"detail": "下班路上买鸡蛋、青菜和牛奶。"
},
{
"id": "todo-002",
"title": "写周报",
"status": "todo",
"priority": "high",
"detail": "整理本周完成的接口联调、遗留问题和下周计划。"
},
{
"id": "todo-003",
"title": "回复邮件",
"status": "todo",
"priority": "medium",
"detail": "回复项目会议纪要,确认接口字段和上线时间。"
}
]list_todos 不需要返回完整详情,只返回能判断下一步的信息。get_todo 再按 id 查详情。这样拆开以后,模型第一轮不会被一大堆细节淹住。
三、工具函数
先写普通 Python 函数,暂时不接模型。
新增文件:scripts/todo_tools.py
python
#!/usr/bin/env python3
"""待办查询工具函数。"""
import json
from pathlib import Path
TODO_PATH = Path("todos.json")
def load_todos():
"""读取本地待办文件。"""
return json.loads(TODO_PATH.read_text(encoding="utf-8"))
def list_todos():
"""返回待办摘要,先不返回 detail,避免列表结果太长。"""
return {
"ok": True,
"items": [
{
"id": item["id"],
"title": item["title"],
"status": item["status"],
"priority": item["priority"],
}
for item in load_todos()
],
}
def get_todo(todo_id):
"""按 id 返回单条待办详情。"""
for item in load_todos():
if item["id"] == todo_id:
return {"ok": True, "item": item}
return {"ok": False, "error": f"待办不存在: {todo_id}"}
if __name__ == "__main__":
print(list_todos())
print(get_todo("todo-002"))运行:
bash
uv run python scripts/todo_tools.py输出会比较长,大概能看到两部分:
text
{'ok': True, 'items': [{'id': 'todo-001', 'title': '买菜', ...}]}
{'ok': True, 'item': {'id': 'todo-002', 'title': '写周报', ...}}工具函数能单独跑通,后面接模型时就少一层不确定。模型传错参数是一类问题,Python 函数自己读不到文件又是另一类问题,分开看会清楚很多。
四、工具定义
工具定义是写给模型看的。Python 有 list_todos() 和 get_todo(),模型并不会自动知道这两个函数存在,需要把函数名、用途和参数结构告诉它。
python
TOOLS = [
{
"type": "function",
"name": "list_todos",
"description": "查询所有待办的标题、状态和优先级。",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
"strict": True,
},
{
"type": "function",
"name": "get_todo",
"description": "按 todo_id 查询一条待办的完整详情。",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "string",
"description": "待办 id,例如 todo-002",
}
},
"required": ["todo_id"],
"additionalProperties": False,
},
"strict": True,
},
]list_todos 没有参数,所以 properties 是空对象。get_todo 只有一个参数 todo_id。这里继续开 strict=True,模型返回的参数会更贴近 schema,Python 这边也更容易处理。
description 不用写得很花,关键是让模型知道什么时候该用。list_todos 是看全局列表,get_todo 是查单条详情,这两个用途分清楚,模型才不容易一上来就瞎填某个 id。
五、输入历史
Agent 循环里最重要的对象是历史记录。这里用一个列表保存每一轮的输入、模型输出和工具结果:
python
input_items = [
{
"role": "user",
"content": "今天还有哪些没完成?把优先级最高的一项展开说明。",
}
]第一次请求后,模型可能返回 function_call。这时要把模型的输出也放进 input_items,再追加工具结果。
python
input_items.extend(response.output)
input_items.append({
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": json.dumps(tool_result, ensure_ascii=False),
})这块比较绕。function_call_output 不是随便一段文字,它要带上 call_id,这样模型才能知道这份结果对应刚才哪一次工具调用。少了这个关联,模型就像听到一句没头没尾的“查到了”,但不知道查的是谁。
05 里用过 previous_response_id,那种写法适合把一次工具回填快速跑通。这里为了把循环看得更清楚,手动保存 input_items:模型每一轮说了什么、Python 每次返回了什么,都在列表里。
六、工具路由
模型返回工具调用以后,Python 不能直接按字符串执行函数。比如模型返回 name="delete_all",本地根本不该有这种入口。工具路由要写成白名单。
python
def dispatch_tool(tool_call):
"""根据模型请求执行本地工具,所有工具都走白名单。"""
try:
arguments = json.loads(tool_call.arguments or "{}")
except json.JSONDecodeError as exc:
return {"ok": False, "error": f"工具参数不是合法 JSON: {exc}"}
if tool_call.name == "list_todos":
return list_todos()
if tool_call.name == "get_todo":
todo_id = arguments.get("todo_id")
if not isinstance(todo_id, str) or not todo_id.strip():
return {"ok": False, "error": f"todo_id 不合法: {todo_id!r}"}
return get_todo(todo_id)
return {"ok": False, "error": f"未知工具: {tool_call.name}"}这里的错误也作为工具结果返回,而不是直接让脚本崩掉。比如模型把 todo_id 漏了,工具返回:
json
{"ok": false, "error": "todo_id 不合法: None"}模型拿到这个结果后,可能会重新调用 list_todos 或直接说明资料不足。至少 Python 这边没有把一个坏参数继续传下去。
七、循环脚本
完整脚本放在一个文件里,方便看清楚 Agent 循环长什么样。
新增文件:scripts/todo_agent_loop.py
python
#!/usr/bin/env python3
"""一个最小的待办 Agent 循环。"""
import argparse
import json
import os
import sys
from pathlib import Path
from openai import OpenAI
TODO_PATH = Path("todos.json")
TOOLS = [
{
"type": "function",
"name": "list_todos",
"description": "查询所有待办的标题、状态和优先级。",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
"strict": True,
},
{
"type": "function",
"name": "get_todo",
"description": "按 todo_id 查询一条待办的完整详情。",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "string",
"description": "待办 id,例如 todo-002",
}
},
"required": ["todo_id"],
"additionalProperties": False,
},
"strict": True,
},
]
def parse_args():
parser = argparse.ArgumentParser(description="run todo agent loop")
parser.add_argument("question", help="要询问待办 Agent 的问题")
parser.add_argument("--max-steps", type=int, default=5, help="最多允许几轮工具调用")
return parser.parse_args()
def load_todos():
if not TODO_PATH.exists():
return []
return json.loads(TODO_PATH.read_text(encoding="utf-8"))
def list_todos():
return {
"ok": True,
"items": [
{
"id": item["id"],
"title": item["title"],
"status": item["status"],
"priority": item["priority"],
}
for item in load_todos()
],
}
def get_todo(todo_id):
for item in load_todos():
if item["id"] == todo_id:
return {"ok": True, "item": item}
return {"ok": False, "error": f"待办不存在: {todo_id}"}
def dispatch_tool(tool_call):
"""只执行白名单里的工具,参数异常也包装成工具结果。"""
try:
arguments = json.loads(tool_call.arguments or "{}")
except json.JSONDecodeError as exc:
return {"ok": False, "error": f"工具参数不是合法 JSON: {exc}"}
if tool_call.name == "list_todos":
return list_todos()
if tool_call.name == "get_todo":
todo_id = arguments.get("todo_id")
if not isinstance(todo_id, str) or not todo_id.strip():
return {"ok": False, "error": f"todo_id 不合法: {todo_id!r}"}
return get_todo(todo_id)
return {"ok": False, "error": f"未知工具: {tool_call.name}"}
def run_agent(client, model, question, max_steps):
input_items = [
{
"role": "user",
"content": question,
}
]
trace = []
for step in range(1, max_steps + 1):
response = client.responses.create(
model=model,
instructions=(
"你是一个待办清单助手。"
"需要查询待办时使用工具。"
"回答只能基于工具返回的数据。"
),
tools=TOOLS,
input=input_items,
max_output_tokens=500,
)
# 模型本轮输出也要保留下来,下一轮才能接着刚才的工具调用往下走。
input_items.extend(response.output)
function_calls = [
item for item in response.output
if item.type == "function_call"
]
if not function_calls:
return {
"answer": response.output_text.strip(),
"trace": trace,
"stopped_by": "answer",
}
for tool_call in function_calls:
tool_result = dispatch_tool(tool_call)
trace.append({
"step": step,
"tool": tool_call.name,
"arguments": tool_call.arguments,
"result": tool_result,
})
print(
f"step {step} call {tool_call.name} {tool_call.arguments}",
file=sys.stderr,
)
input_items.append({
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": json.dumps(tool_result, ensure_ascii=False),
})
return {
"answer": "工具调用次数超过上限,停止执行。",
"trace": trace,
"stopped_by": "max_steps",
}
def main():
args = parse_args()
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL")
if not api_key:
print("缺少环境变量: OPENAI_API_KEY", file=sys.stderr)
return 2
if not model:
print("缺少环境变量: OPENAI_MODEL", file=sys.stderr)
return 2
client = OpenAI(api_key=api_key, timeout=60.0)
result = run_agent(
client=client,
model=model,
question=args.question,
max_steps=args.max_steps,
)
output_path = Path("outputs/todo-agent-trace.json")
output_path.parent.mkdir(exist_ok=True)
output_path.write_text(
json.dumps(result, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(result["answer"])
print(f"trace 已保存: {output_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())运行:
bash
uv run python scripts/todo_agent_loop.py "今天还有哪些没完成?把优先级最高的一项展开说明。"stderr 里会看到工具调用过程:
text
step 1 call list_todos {}
step 2 call get_todo {"todo_id":"todo-002"}
trace 已保存: outputs/todo-agent-trace.json标准输出可能类似:
text
今天还有两项没完成:写周报和回复邮件。优先级最高的是“写周报”,需要整理本周完成的接口联调、遗留问题和下周计划。这个回答经历了两轮工具调用。第一轮拿列表,第二轮拿详情。模型不是一开始就知道 todo-002 的内容,它是在看到 list_todos 的结果以后,才继续请求详情。
八、停止条件
Agent 循环不能无限跑。这个脚本有两个停止条件。
第一种是模型不再返回 function_call,而是返回普通回答。代码里看到 function_calls 为空,就把 response.output_text 当成最终结果。
第二种是超过 --max-steps。默认最多 5 轮工具调用:
bash
uv run python scripts/todo_agent_loop.py "今天还有哪些没完成?" --max-steps 1如果模型第一轮只调用了 list_todos,还没来得及整理回答,脚本就会停在:
text
工具调用次数超过上限,停止执行。这个上限不是为了刁难模型,是为了防止它一直查同一个工具。比如工具描述写得太含糊,模型可能反复调用 list_todos,每次都拿到同一份列表。没有上限的话,脚本会一直烧请求。
还有一种细节:有些模型可能在同一轮里既返回一段说明文字,又返回 function_call。这个脚本只要看到 function_call,就继续执行工具,暂时不把那段说明当最终回答。因为那段文字经常只是“我需要先查询待办列表”这种中间话,不是最后结果。
九、中间结果
Agent 出问题时,最怕只看到最后一句回答,不知道中间查了什么。脚本里用 trace 保存了每一步工具调用:
json
{
"step": 1,
"tool": "list_todos",
"arguments": "{}",
"result": {
"ok": true,
"items": [
{
"id": "todo-001",
"title": "买菜",
"status": "done",
"priority": "low"
}
]
}
}完整结果会写到:
text
outputs/todo-agent-trace.json如果回答说“优先级最高的是回复邮件”,但 trace 里明明 todo-002 的 priority 是 high,那问题多半出在模型理解工具结果这一段。如果 trace 里压根没有 todo-002,那就回到 list_todos 或 todos.json 看数据有没有读出来。
这比单纯打印最终回答好查很多。Agent 不是只看结果,过程也要留痕;工具调用、参数、返回值都保存下来,后面才知道它是怎么走到这个答案的。
十、当前结构
这篇里的文件关系很少:
text
todos.json
scripts/todo_tools.py
scripts/todo_agent_loop.py
outputs/todo-agent-trace.jsontodos.json 是资料,todo_tools.py 是普通 Python 工具,todo_agent_loop.py 把模型判断和工具执行串起来。outputs/todo-agent-trace.json 保存每次运行的中间过程。
这就是一个很小的 Agent 骨架:模型负责判断下一步,Python 负责执行工具,历史记录把每一步接起来,停止条件防止循环失控。工具换成 FAQ、文档检索、接口查询、日志读取时,骨架还是这一套。