跳到主要内容

手写 Agent 第 1 课:最小循环

第 1 课:最小循环​

本课已完成手写与断点调试。核心是:模型提出工具调用请求,Python 执行工具,把结果追加到 messages,再请求模型;没有工具调用请求时结束,达到轮数上限时强制停止。

自己的实现见 code/01-minimal-loop.py,环境配置见 环境准备,参考教程见 NamiAgent 第 1 课。本文按当前代码整理,重点记录返回结构和消息流转。

工具与模型的分工​

三个工具与两份数据​

工具就是普通的 Python 函数。模型拿到工具说明后,生成“调用哪个函数、传什么参数”的结构化请求;真正读文件、写文件和执行命令的是本地 Python 代码。

工具参数用途
read_filepath读取文件内容
write_filepath、content写入文件,覆盖原内容
run_shellcommand执行命令并返回输出

当前实现中,有两份名字相近、用途不同的数据:

  • TOOLS:工具说明列表,传给模型,包含名称、描述和参数结构。def_tools() 负责生成这些说明。
  • TOOL_MAP:函数映射字典,留在本地,根据模型返回的工具名找到 Python 函数。
TOOL_MAP = {
"read_file": read_file,
"write_file": write_file,
"run_shell": run_shell,
}

左边的 "write_file" 是字符串键,右边的 write_file 是函数对象。没有加调用括号,所以定义字典时不会执行函数。

什么任务容易触发工具调用​

“给我一段打印 hello 的代码”通常直接回复文字就能完成。“实际创建文件,再读取并运行它”则需要操作本地环境。

当前代码末尾的任务要求依次使用 write_file、read_file、run_shell:创建 tool_demo.py,写入 print(17 * 23),读回检查,再运行并总结实际输出。

这只是预期流程。模型可能一轮请求多个工具,也可能分几轮请求。后一个操作需要前一个工具的实际结果时,通常需要下一轮才能作出判断。

📌 当前 01-minimal-loop.py 在末尾定义 task,然后执行 run(task)。修改任务要改这个字符串;当前入口没有读取 sys.argv[1],在启动命令后追加任务文本不会替换它。

create 的返回结构​

这次调用发出了什么​

resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)

三个参数分别表示:

  • model:使用哪个模型。代码中的默认值是 deepseek-flash,启动前可以调整。
  • messages:到目前为止的对话,包括任务、模型回复和已有工具结果。
  • tools:本轮向模型提供的工具说明。

当前没有设置 stream=True,所以拿到的是一次完整的非流式响应。虽然使用 OpenAI Python SDK,当前默认请求地址是 DeepSeek;SDK 将兼容接口返回的 JSON 解析成 ChatCompletion 对象,赋值给 resp。

resp 包含本轮模型响应,不是完整对话历史,也不是工具已经执行后的结果。 工具请求返回后,还需要下面的 Python 循环来执行。

情况一:直接回复文字​

下面是用于阅读结构的简化 JSON,编号和正文均为示例,不是本次调试的真实记录。省略了 created、usage 等字段;实际响应还可能包含服务商或模型相关字段。

{
"id": "chatcmpl_text_demo",
"object": "chat.completion",
"model": "deepseek-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "你好!有什么需要我帮忙的吗?",
"tool_calls": null
},
"finish_reason": "stop"
}
]
}

message.content 是正文。这个例子没有工具调用,所以 tool_calls 为 null,在 Python 中对应 None。实际响应也可能省略这个可选字段,SDK 中通常仍能读到 None。

finish_reason 属于 choices[0],说明本轮生成为什么结束。这里的 stop 表示正常结束生成;它本身不证明外部任务已经完成。

情况二:请求调用工具​

假设任务是“在当前目录创建 tool_note.txt,内容为 hello”,模型可能返回:

{
"id": "chatcmpl_tool_demo",
"object": "chat.completion",
"model": "deepseek-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_write_001",
"type": "function",
"function": {
"name": "write_file",
"arguments": "{\"path\":\"tool_note.txt\",\"content\":\"hello\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}

这个例子表达的是:请调用 write_file,参数是文件路径和文件内容。看到这个返回时,文件还没有被本地代码写入。

重点看这些字段:

字段含义
choices候选回复列表
choices[0]第一个候选回复
message该候选中的助手消息
message.content正文,可能为 None
message.tool_calls本轮工具调用请求列表
tool_calls[0].id第一条工具调用的编号
function.name要调用的工具名
function.argumentsJSON 格式的参数字符串
finish_reason本轮生成结束的原因

choices[0] 取的是本轮第一个候选回复,不是 messages 中第一条历史消息。tool_calls[0] 才是这条助手消息中的第一个工具调用请求。

工具请求可以带有文字正文,所以不能只靠 content 是否为空判断是否需要工具。当前实现检查的是 msg.tool_calls。返回字段依据见 DeepSeek Chat Completions 文档。

怎样看见自己的真实返回​

在 create() 后面临时打印:

print(type(resp))
print(resp.model_dump_json(indent=2))

msg = resp.choices[0].message
print(msg.model_dump_json(indent=2))

model_dump_json() 将 SDK 对象转成 JSON 字符串;indent=2 让每层缩进两个空格。这是 SDK 解析后的响应视图。

也可以转成字典后访问,两种写法对应同一份数据:

data = resp.model_dump()

print(resp.choices[0].message.content) # 对象访问
print(data["choices"][0]["message"]["content"]) # 字典访问

从响应取出工具调用​

逐层理解取值路径​

msg = resp.choices[0].message

拆开后是:

choices = resp.choices # 取出候选回复列表
choice = choices[0] # 取出第一个候选回复
msg = choice.message # 取出助手消息

SDK 对象用点号取属性,列表用下标取元素。拿到 msg 后,再遍历它请求的工具:

for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments)
result = str(TOOL_MAP[name](**args))

这里的 call 是列表里当前遍历到的一条调用请求。实际代码在进入循环前,已经通过 if not msg.tool_calls 排除了没有工具请求的情况。

arguments 为什么还要解析​

示例中的 arguments 看起来像一个对象,但外面有引号,它实际是字符串。内部的 \" 是 JSON 字符串里的双引号转义。

raw = call.function.arguments
# raw 是字符串:'{"path":"tool_note.txt","content":"hello"}'

args = json.loads(raw)
# args 是字典:{"path": "tool_note.txt", "content": "hello"}

解析后的字典,用 JSON 表示就是:

{
"path": "tool_note.txt",
"content": "hello"
}

外层 SDK 对象已经被解析,不代表 arguments 这个字段里的 JSON 字符串也会自动变成字典。因此这里还需要 json.loads()。

根据名字执行本地函数​

result = str(TOOL_MAP[name](**args))

这句可以拆成三步:

func = TOOL_MAP[name] # 根据工具名取出函数
value = func(**args) # 展开参数并执行函数
result = str(value) # 把函数返回值转成字符串

使用上面的写文件参数时,相当于:

result = str(write_file(path="tool_note.txt", content="hello"))

**args 把字典展开成关键字参数,字典的键需要匹配函数参数名。这里传入的是 path 和 content 两个参数。

它与 Java 反射都有“运行时选择调用目标”的使用场景;这里的机制更接近在 Map 中保存函数式接口,取出来后调用,不需要通过类元数据查找方法。

messages 如何记录过程​

每条消息是谁写的​

消息角色谁生成内容记录什么缺少后的影响
system开发者助手职责和行为要求少了这层行为约束
user用户,本例为 task本次任务没有本次任务内容
assistant,带工具请求模型工具名、参数和调用编号工具结果缺少对应请求
toolPython 工具执行代码执行结果或错误信息模型不知道执行结果
assistant,文字回复模型回答或任务总结少了这条回复记录

这些消息都由 Python 代码放入 messages。其中,模型生成的助手消息由 SDK 返回,工具结果由本地函数返回。

先记请求,再记结果​

拿到助手消息后,先追加它:

messages.append(msg.model_dump(exclude_none=True))

model_dump() 把 SDK 消息对象转成字典,exclude_none=True 表示省略值为 None 的字段。追加的是 msg,不是包含 choices 等信息的完整 resp。

执行每一个工具后,再追加对应结果:

messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})

tool_call_id 必须匹配原调用请求的 id。它用来告诉模型:这条结果属于哪一次调用,而不是只按工具名关联。

下面展示一次写文件再总结的简化消息历史。为突出结构,省略了实际模型可能返回的其他消息字段:

[
{
"role": "system",
"content": "你是一个能操作本地文件和命令行的编程助手。需要时调用工具,完成任务后直接用文字总结。"
},
{
"role": "user",
"content": "请在当前目录创建 tool_note.txt,内容为 hello。"
},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_write_001",
"type": "function",
"function": {
"name": "write_file",
"arguments": "{\"path\":\"tool_note.txt\",\"content\":\"hello\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_write_001",
"content": "已写入 tool_note.txt(5 字符)"
},
{
"role": "assistant",
"content": "已创建 tool_note.txt,写入内容为 hello。"
}
]

第五条消息来自工具执行后的下一轮模型响应。模型会看到前四条记录,再根据执行结果生成总结。这段 JSON 是说明用的示例,不代表程序已经实际执行了写文件操作。

如果同一条助手消息请求了多个工具,代码要执行并记录每个请求的结果,再进入下一轮模型请求。当前内层 for 是顺序执行,没有并发执行工具。

📌 工具报错也要回填。当前 try/except 会把文件不存在、参数错误等异常转成文字,作为 tool 消息交给模型;下一轮模型可能修正参数,也可能解释失败后结束。

循环何时继续和停止​

两种正常停止路径​

for step in range(1, MAX_STEPS + 1):
# 请求模型,记录助手消息

if not msg.tool_calls:
print(msg.content)
return

# 执行本轮全部工具,记录结果,进入下一轮

这是省略了请求与执行细节的流程片段。正常控制流程中有两种停止路径:

  • 模型本轮没有请求工具:if not msg.tool_calls 成立,输出正文,通过 return 退出 run()。
  • 模型持续请求工具:最多完成 MAX_STEPS 轮请求,循环结束后输出达到上限的信息。当前上限为 20。

range(1, MAX_STEPS + 1) 在这里产生 1 到 20,不包含 21。上限限制的是模型请求轮数,不是工具执行总次数。 一轮可以包含多个工具请求,最后一轮纯文字总结也占一轮。

工具返回结果并追加到 messages,不会自动再次调用模型。真正让流程继续的是外层 for 的下一次迭代,以及下一次 create()。

停止不等于验证成功​

当前代码的结束条件是“没有工具调用请求”,不是“已验证任务成功”。模型解释失败后也可能不再请求工具;达到上限则表示流程被强制停止。

finish_reason="length" 等情况还可能表示生成被截断。当前最小实现没有按 finish_reason 进一步区分这些情况;它仍然只检查 msg.tool_calls。网络或鉴权等模型请求异常也没有被工具的 try/except 捕获,会直接中断程序。

本课先理解这个边界,不把最小循环的“停止”当成完整的业务验收。

断点调试​

重点查看的位置​

在 msg = resp.choices[0].message 的下一行设置断点。暂停时模型请求已经返回,resp 和 msg 都有值,可以依次展开:

resp
choices
[0]
message
content
tool_calls
[0]
id
function
name
arguments

在工具执行后的 messages.append(...) 下一行再设置断点,查看新追加的 tool 消息,以及它的 tool_call_id 是否对应原请求的 id。

调试器还会显示 SDK、Pydantic 和 Python 使用的内部属性。_xxx 通常约定供内部使用,__xxx__ 通常是特殊属性;本课先关注上面的消息与工具字段。

工作目录影响工具操作​

文件读写和命令执行没有显式切换目录,所以相对路径以 Python 进程的当前工作目录为准。运行或调试时,将工作目录设为 项目里的 playground 目录,不要让示例文件落到仓库根目录。

run_shell 每次执行命令前要求输入 y/n;当前 write_file 和 read_file 没有这一步。命令被拒绝时,返回的“用户拒绝执行该命令”也会作为工具结果进入下一轮。

本课实现记录​

与参考代码的差异​

以下差异按当前 code/01-minimal-loop.py 与 NamiAgent 的 lesson-01/agent.py 核对:

  • 配置读取:自己的实现通过 code/model_client.py 统一加载配置文件,导入 MODEL 并调用 create_client()。参考代码直接在文件内创建客户端。
  • 工具说明与分发:自己的函数名是 def_tools(),映射名是 TOOL_MAP;参考代码分别叫 _tool() 和 TOOL_FUNCS。当前两种说明构造函数都把简写的参数说明展开成字符串参数的 JSON Schema。
  • 工具返回值:自己的实现使用 str(...) 将函数返回值转成字符串,再放入 tool.content;参考实现直接使用工具返回值。
  • 任务入口:自己的实现把三工具任务写在文件末尾并调用 run(task);参考实现通过 if __name__ == "__main__" 读取命令行参数。

外层模型请求循环、内层工具执行循环、助手消息与工具消息回填,以及轮数上限的核心逻辑一致。

参考资料​

更新记录:2026-11-01 完成本课复习笔记,补充返回 JSON、取值路径、工具执行、消息回填和停止条件;同日按课编号更新脚本路径;示例数据不代表新的真实模型调用。