剥离了框架的黑盒后,上一篇文章我们已经把 Agent 拆解为模型、工具、记忆和循环四个核心模块。这一篇我们硬核到底,彻底抛弃重型框架,用几十行 Python 代码手搓出最核心的调度循环(Loop)。在这套实现里,openai SDK 只是被当作一个 HTTP 客户端透传使用,天气查询后端则接入了免鉴权的 Open-Meteo。
相比于套壳调用现成的 API,普通单次问答在发起请求后就草草收场。但在 Agent 的底层架构里,一旦模型提出调用工具的诉求,我们的代码必须精准接管执行流,把执行结果“回灌”进 messages 数组,然后再次发起请求。这一过程不断循环迭代,直到模型不再请求工具,或者触发系统预设的退出机制。必须明确一点:Agent 展现出的所谓“智能”,并非大模型算力的凭空飞跃,而是由这套严密的调度循环所赋予的。
为了确保后续讨论站在同一个认知基准上,下面列出全文通用的核心术语。在架构视角的语境下,它们各自代表着特定的流转机制:
| 术语 | 架构层含义 |
|---|---|
messages | 透传给大模型的对话上下文数组,承载了所有的状态机流转 |
| schema | 注入到 tools 参数中、专门暴露给大模型的工具 JSON 描述 |
| 回灌 | 将工具执行的结果或抛出的 error 封装为 role=tool 格式,并追加写入 messages 的动作 |
| 配对 | 保证单条 assistant.tool_calls 与回灌的 tool 消息在 tool_call_id 上达到严格的 1:1 映射 |
| 重试 | 捕获可恢复异常后,引导模型更换参数并再次发起同名工具调用 |
| 终止 | 直接中断调度循环,拒绝将不可恢复的错误进行回灌或重试 |
| 出口 | 循环的安全阀,包含无 tool_calls 响应、触达轮数上限、累计 token 溢出等触发条件 |
| 终答 | 当模型不再下发 tool_calls 指令时,assistant 角色输出的最终 content |
| 打转 | 模型陷入幻觉,连续两次以完全相同的参数调用同名工具的死循环行为 |
在底层调度的视角下,模型只负责表达「意图」——即想要调用哪个工具。而真正的路由分发、执行回灌乃至异常兜底,必须全部由你手搓的代码来绝对掌控。
Table of contents
Open Table of contents
一、从代码层面透视底层差异
要理解核心调度循环,第一步就是把普通问答与 Agent 的差异直接打平到代码上。
在单次问答的场景下,代码逻辑极其扁平:发起请求、提取 content、流程随即结束。
resp = client.chat.completions.create(model=MODEL, messages=messages)
print(resp.choices[0].message.content)
然而一旦演进到 Agent 架构,API 端点、大模型底座甚至鉴权机制都不需要改动,核心变量仅仅是在 Payload 中增补了 tools 参数,从而对模型开放了工具调用权限:
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOL_SCHEMAS,
tool_choice="auto"
)
此时最棘手的问题恰恰出在这里:响应的形态发生了本质的质变。大模型极有可能把 content 字段留空,转而在 tool_calls 结构体里声明「我要路由到 search_location,并透传参数 {"name": "西安"}」。在这一步,如果业务逻辑依然死板地去解析 content,拿到的一定是引发异常的 None。
将两者的状态流转机制摊开来对比,差异一目了然:
| 维度 | 普通问答架构 | Agent 调度循环 |
|---|---|---|
| 网络请求频次 | 恒定为 1 次 | 1 到 N 次,N 在运行时动态决定 |
| 决策权归属 | 开发者(预先写死的代码执行路径) | 大模型(每轮迭代基于上下文重新推理) |
| 模型输出形态 | 单一的纯文本流 | 文本流或者结构化的 tool_calls 数组 |
| 工具执行方 | —— | 开发者手搓的代码(模型运行在沙盒外,无法直接触达物理机) |
| 流程终止条件 | 接收到 HTTP 响应即刻退出 | 依赖架构师精心设计的出口策略 |
| 异常兜底机制 | 向上层抛出 Exception | 能通过修改参数修复的则回灌,系统级错误则硬终止 |
表格最后两行点透了本篇的核心命题:异常兜底和出口控制绝对不是大模型的职责,而是调度循环必须承担的工程基建。 倘若缺少外部约束,大模型绝不会主动触发“拒答”或声明“系统卡死”。它只会像一台无情的算力机器一样配合你——你下发一百次死循环请求,它就产生一百次毫无意义的 Token 消耗。
二、解剖调度循环:决定架构成败的三处工程细节
为了先建立宏观认知,我们把主干的调度逻辑剥离出来。至于工具内部的 AST 解析或业务细节,留到下一节再展开。
# 核心调度循环示意(剥离了非关键的工程样板代码)
def run(goal: str, max_turns: int = 8) -> str:
# 1. 组装初始的上下文状态
messages = [...]
# 2. 状态机流转:请求大模型 → 侦测到 tool_calls 则触发路由分发
for _ in range(max_turns):
resp = client.chat.completions.create(...)
msg = resp.choices[0].message
# 3. 正常出口:本轮模型不再下发工具请求,状态机收敛,直接返回终答。
if not msg.tool_calls:
return msg.content or ""
# 4. 关键动作:必须将「模型意图声明」的这一帧状态原封不动地回写进 messages 数组。
messages.append(...)
# 5. 路由与执行:遍历工具请求,执行本地代码后将结果按 tool_call_id 一一配对回灌
for tc in msg.tool_calls:
result = dispatch(tc.function.name, tc.function.arguments)
messages.append(...)
return "系统调度达到最大轮数仍未收敛,触发强行终止。"
剥开看似高深的外衣,底层逻辑极其纯粹。这里没有什么隐藏状态机,所谓的链式推理过程,早已被彻底打平并记录在这个名为 messages 的 List 结构里。
但在真正的工程实战中,有三处微小却致命的细节,往往是区分 Demo 和生产级代码的分水岭:
第一,大模型下发的那条包含 tool_calls 的原始消息,必须原模原样地落盘到 messages 中。 一旦在流转中丢失这一帧,下一轮你强行挂载的 role: "tool" 就会变成毫无渊源的孤儿节点,服务端 API 会毫不留情地掷回 400 错误。messages 的时序记录里,必须留下“模型确实发出过调用请求”这个明确锚点,回灌的数据才具备上下文合法性。
第二,tool_call_id 的配对机制容不得半点偏差。 遇到并发调度场景,模型在一轮交互中可能同时下发三个工具指令,此时你必须并发执行并回灌三条对应的 tool 消息,且 id 必须严丝合缝地一一对齐。哪怕只漏掉一条,服务端同样会以 400 错误拦截——它绝不会宽容地认为这只是“丢失了部分信息”。
第三,使用 for _ in range(max_turns) 绝不是教条的防御性编程,而是架构设计的硬性主干逻辑。 如果你图省事用 while True 去糊代码,一旦遭遇大模型幻觉并陷入参数打转的死循环,最终只能在月底暴涨的 API 账单上体会到架构失控的惨痛代价。
三、工具抽象层:Schema 是面向大模型的 API 接口
在手搓 Agent 的体系里,工具必须被抽象割裂为两层——暴露给模型进行意图理解的 Schema,以及真正在物理机上干脏活累活的 Python 函数。前者是协议层,直接决定了模型能否在正确的上下文语境中命中路由,以及参数生成的校验合法性;后者则是具体的业务逻辑层。
为了在实战中彻底跑通多轮次的状态机流转,本次我们特意挑了两个具有强时序依赖的工具:天气接口强制要求输入经纬度,而用户 Query 通常只包含城市名称。因此模型必须具备多步推理能力,先通过 search_location 解析出坐标,随后才能流转到 get_current_weather 节点。
# 面向模型的 Schema 契约定义示例
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "search_location",
"description": "按地名搜索地理坐标。当上下文中仅包含城市名、缺乏经纬度实体时,必须作为前置节点被优先调度。",
# ... parameters ...
},
},
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "按经纬度获取当前天气。如果在状态机中尚未获取坐标,请务必先路由到 search_location 节点进行解析。",
# ... parameters ...
},
},
]
务必注意,description 字段里那句“如果在状态机中尚未获取坐标,请务必先路由到 search_location 节点进行解析”,绝不是写给协作者看的无用注释,它是驱动大模型完成多步逻辑编排的唯一推手。如果手贱把它剔除,模型有极大概率会直接绕过前置节点,对着 get_current_weather 凭空捏造一组幻觉坐标。它不会抛任何异常,而是理直气壮地返回一个看似完美实则荒谬的错误答案。关于 Schema 描述的 Prompt 工程和参数约束,我们留到下一篇专题拆解;眼下只需铭记一点:大模型的视野被严格限制在 Schema 这一层,它对底层的函数实现一无所知。
而在路由分发模块(Dispatch),必须坚守一条底线纪律:模型输出的 arguments 本质上不过是一段由概率模型生成的非结构化字符串,属于绝对不可信的外部输入,必须经过严密校验。
# 路由分发与异常兜底架构示意
def dispatch(name: str, raw_args: str) -> dict:
# 1. 动态映射函数指针
# 2. 严谨解析 JSON 字符串
# ...
try:
return fn(**args)
except TypeError as e:
return {"error": f"参数类型推断失败,无法对齐 Schema: {e}"}
# 严禁使用 except Exception 进行无差别兜底,这会污染回灌状态!
需要着重指出的是,这几类异常必须用 return 而非 raise 的方式向外抛出。至于为什么状态流转要这样设计,第五节会深度剖析。
四、观测状态演进:messages 数组的增量膨胀
在无状态的调度架构中,messages 数组本身就是 Agent 唯一的、不可变的状态源。由于它采用 Append-only 的增量日志模式,每一轮迭代都会把历史的因果关系全部打包透传,从而让大模型能够“记住”自己之前下发过哪些指令。四种角色在这个状态机里各司其职:system 负责注入全局规则,user 提供业务目标,assistant 输出中间推导或下发工具指令,而 tool 则负责提交底层执行的回执。
当我们追踪一次“西安现在天气怎么样”的实际执行链路时,状态机的流转如下:
- 第 1 轮流转:模型通过上下文感知到只有城市名称实体,主动请求下发
search_location(name="西安")指令。调度层接管执行,并把结构化结果{"latitude": 34.26, "longitude": 108.94, ...}回灌至队列。 - 第 2 轮流转:模型从上下文中提取到合法的坐标实体,再次请求下发
get_current_weather(latitude=34.26, longitude=108.94)。调度层同步执行并回灌环境数据(温度、湿度、风速等)。 - 第 3 轮流转:推理所需的要素已全部齐备,模型判断无需继续调用工具,生成最终的文字答复。此时
tool_calls为空,调度循环平滑命中退出条件。
这张看似简单的流转图,其实隐藏了两笔必须在工程后期偿还的技术债务。其一,在每一轮 HTTP 交互中,全量的 messages 快照都会被无情重发。在 N 轮迭代下,整体 Token 吞吐量将呈现接近 N²/2 级别的恶性膨胀。其二,由于状态数组只增不减,最终必然会撞破大模型设定的上下文窗口截断线。如果工具返回的原始 JSON 过于冗杂,这个崩溃点只会来得更早。针对这些痛点,如何在架构层引入字段裁剪、上下文压缩算法以及 Token 监控,我们会在后续文章中系统探讨,这里暂且按下不表。
五、异常治理:拒绝向外抛错,以“回灌”修复幻觉
当本地工具的执行遭遇失败时,传统开发者的肌肉记忆往往是直接 raise 抛出异常,这会让整个调度循环在一瞬间崩溃。但在 LLM 原生的架构范式里,许多失败完全可以通过大模型的自我纠偏能力来修复——比如参数结构错乱、城市名称拼写失误,或是字段命名产生了幻觉。只要你能捕获这些 error,把它格式化后回灌进 messages,模型在接收到失败回执后,通常会在下一轮迭代中自行调整参数并重发请求。一旦你粗暴地把异常抛出循环,这种优雅的自愈重试机制就彻底灰飞烟灭了。
# 将底层的 exception 封装为规范的 tool 消息体,执行回灌操作
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"error": "解析失败:未能匹配城市 Tokoy,请检查拼写是否产生幻觉"}, ensure_ascii=False),
}
)
但这绝不意味着要毫无底线地对所有异常进行兜底回灌。判断是否需要交由大模型处理的核心指标非常纯粹:这个特定类型的错误,大模型能通过修正调用参数来扭转局面吗?
| 故障类别 | 架构层应对策略 | 决策依据 |
|---|---|---|
| 参数格式校验失败 / 枚举值越界 | 执行回灌,触发自我纠偏 | 典型的参数层问题,大模型具备强大的纠偏能力 |
| 路由到了不存在的工具名(幻觉) | 执行回灌,强行注入合法的可用工具清单 | 同上,大模型在获取准确上下文后会重新选择正确路由 |
| 业务逻辑引发的空结果(如查无此城) | 正常回灌 | 并非系统故障,而是需要模型据此进行下一阶段推理的有效输入 |
| 上游服务触发 429 熔断 / 5xx 宕机 | 在工具内部闭环执行指数退避重试,退避耗尽后再回灌 | 此时修改参数毫无意义,坚决不能让高成本的大模型充当无脑重试器 |
| 鉴权证书失效、API 配额被击穿 | 直接触发架构层硬终止 | 这种系统级错误哪怕循环一百次也无济于事,只会白白烧钱 |
| 开发者手搓代码中的 Bug | 直接触发硬终止 | 盲目回灌只会让模型产生幻觉,把底层 Bug 包装成一本正经的胡说八道 |
关于最后一行,必须敲响警钟。如果出于偷懒,用 except Exception 简单粗暴地把所有底层异常全部兜住并回灌,你的调度循环看起来会坚不可摧——它永远能吐出圆滑得体的回答,而深埋在底层的 KeyError 或空指针异常你永远都无法通过日志观测到。核心原则是:要么利用模型的能力去修补参数,要么通过告警让人类工程师去修复代码,绝不能让大模型沦为你掩盖低级 Bug 的遮羞布。
需要澄清的是,上表中提到的退避机制(Backoff),是指工具内部遭遇 429 或 5xx 状态码时在独立模块内进行的重试,绝不是大模型在调度循环中的重试。至于如何优雅地处理超时回灌,后续章节再做深度拆解。
六、构建安全阀:必须同时锁死的三个出口
第二节的主干逻辑里,为了降低理解门槛,我们只演示了两个基本出口。但在生产环境的架构里,为了兜底各种失控场景,这三个出口缺一不可:
| 出口类型 | 状态机触发阈值 | 缺失该护栏的后果 |
|---|---|---|
| 正常收敛出口 | 侦测到本轮未下发 tool_calls | 状态机永远无法进入终止态,无法向终端输出终答 |
| 轮数阈值熔断 | 达到预设的 max_turns 峰值 | 一旦大模型发生幻觉陷入参数打转,调度器将陷入无限死循环 |
| 累计 Token 熔断 | 当前会话下所有 HTTP 请求的 Token 吞吐量之和触达安全线 | 即使单轮未出现打转,依然可能因对话过长导致 API 费用失控 |
为什么必须把轮数上限作为硬性的熔断机制?因为参数打转是 Agent 架构中最常见、也最棘手的死锁状态:大模型会像着了魔一样,反复调用同一个工具,透传完全相同的参数,每一轮都接收到一模一样的执行结果,但它的内部逻辑却坚信再重试一次就能获得真相。没有这层强行熔断的护栏,它可以毫不留情地把你的账户余额刷到透支。如何在工具执行之前通过中间件拦截这种打转行为,是后续要引入的优化手段;但在最核心的基建里,首先要确保系统有能力“刹得住车”。
此外,关于累计 Token 的熔断,必须以整个 Session 的各轮次之和为基准来计算,绝不能只看单轮。即使单轮请求量看似都在合理配额内,由于 messages 数组每轮都会进行全量上下文重发,当轮次累积后,总 Token 消耗依然会以指数级膨胀并最终打爆限制。
七、源码交付与验证
上述完整代码的开源实现已提交至 agent-in-action/part1-handmade-agent/01-agent-from-scratch/agent.py。为了降低复现门槛,天气服务后端接入了完全免费且免鉴权的 Open-Meteo;同时,大模型层保持了充分解耦,只需简单修改 base_url 环境变量,即可平滑切换至任何兼容 OpenAI 协议的第三方端点。
在调试执行时,请务必紧盯 Console 中输出的 [turn ...] 状态日志:密切关注每一轮路由分发了哪个工具名、透传了什么参数、最终回灌了怎样的结构化结果。当结果偏离预期时,永远先去排查这一轮的调用链路出了什么岔子,而不是盲目地去看大模型的终答内容。这正是为后续建立分布式 Trace 体系打下的监控基础,此刻我们先以最原始的日志把它暴露出来。
八、排坑指南与根因推断
| 观测到的表象 | 根因定位与排查切入点 |
|---|---|
服务端抛出 400,报错 tool 与 tool_call ID 映射错位 | 检查核心循环:大概率是状态落盘时,遗漏了持久化 assistant 下发的 tool_calls 原始声明 |
| 模型拒绝调用任何工具,强行凭借幻觉捏造终答 | 检查 Schema 契约:description 未明确前置调用条件;或需要在 System Prompt 中强注入「严禁依靠先验知识,必须依托工具拉取事实」的规则 |
| 状态机陷入死循环,疯狂复读相同的工具和参数 | 命中打转死锁:首要检查轮数熔断器是否生效;深层原因通常是当前上下文中缺少了它真正想要调用的那个核心工具 |
| 对特定参数字段反复进行非法赋值 | 检查 Schema 契约:在对应参数的 description 中必须严格圈定取值范围和数据类型 |
| 交互轮数尚在安全线内,但 Token 消耗已击穿上限 | 检查底层回执:工具返回的 JSON 过于臃肿,必须在架构层实施字段裁剪和上下文压缩 |
| 单轮响应尚可,整体响应时长却严重拖沓 | 侦测到并发瓶颈:当前实现对同一轮内的多个 tool_calls 采用了串行化执行;后续架构演进需引入并发调度 |
| 系统看似极其稳定从不报错,但输出结果时对时错 | 警惕过度兜底:极大可能是你的异常处理体系被一刀切的 Exception 回灌给污染了,立刻去翻看底层真实的异常栈 |
演进预告
打通最底层的核心调度循环后,决定系统上限的短板就转移到了 Schema 设计上:description 的质量直接决定大模型能否在准确的时机触发路由,而校验失败后的回灌策略则决定系统的整体韧性。在下一篇架构实战中,我们将以一个复杂的“酒店预订”场景为例,为您深度拆解如何构建工业级的 Schema 契约。
架构核心备忘
从底层设计的视角来看,Agent 从来不是一个智力凌驾于基座模型之上的魔法黑盒,其本质就是一个内置了多重异常出口的 while 循环。大模型在这个体系里仅仅充当一个“决策大脑”,负责声称「我想要路由到哪个节点」,而真正的路由分发、状态回灌以及安全出口的熔断,必须彻彻底底由你手搓的代码来绝对掌控。
在工程实践中,必须死守三条不可逾越的底线规则:
tool_calls的意图声明必须原封不动地沉淀进messages队列,否则服务端必以 400 予以拦截;必须在循环外层挂载三重护栏(无tool_calls正常退出、轮数上限熔断、累计 Token 溢出熔断);能通过修正参数自我修复的异常坚决执行回灌,而系统级的致命故障则必须果断终止,绝不恋战。