30 行循环就够了:把 Agent 的复杂度还给业务代码
About 15 min read
从 Anthropic 官方那句「先直接用 API」出发,把一个订单退款 Agent 的运行时压到 35 行,再逐条交代省下来的复杂度去了哪里。
Contents · 9 sections · jump to a heading
2024 年 12 月 19 日,Anthropic 发了一篇《Building effective agents》。文章里列了一串让 agentic 系统更好写的框架,紧接着写的是:建议开发者从直接使用 LLM API 开始,许多模式只需几行代码就能实现。
同一篇里,他们写下了框架的代价,用的是自己的话:这些框架常常制造额外的抽象层,遮蔽底层的 prompt 和响应,让调试变难,也会诱使你在简单方案已经足够的时候添加复杂性。
他们给 Agent 的定义只有一句:LLM 在循环中,基于环境反馈使用工具。 不是规划器,不是记忆体,不是多智能体社会。
这篇文章想回答三个可以被验证的问题:
- 一个不带任何 Agent 框架的运行时,最少要几行代码?
- 哪些逻辑必须留在循环里,哪些必须推到循环外?
- 砍掉框架之后,代价具体落在谁头上?
先把边界划清楚,后面就不必每段都补一句「这只是我的判断」。
- 我没有在生产流量上跑过下面这份代码。 它在本机跑通过,模型调用是真的,退款逻辑是假的(订单存在内存字典里)。本文不出现任何「性能提升 X%」,因为我没有做过框架与裸循环的对照实验。
- 官方数字是官方发布的。 Anthropic 的原文、OpenAI 的函数调用文档,我都贴了链接;我只做了把它们放在一起核对这件事。
- 我给的代码只有 Python 和 TypeScript 两个版本。 其余语言的 SDK 结构一样,我没有逐个验证,所以不写。
- 下文每一处代码都标了它在我仓库里的真实路径和行号,方便你按行核对。仓库是私有的,所以那些路径只是路标,不是可点的外链。
先看结论。整个 Agent 运行时就是下面这个函数,35 行,其中循环体 27 行。
content/_code/minimal-agent/agent.py
1def run(user_text: str, max_steps: int = 8) -> str:2messages = [3{"role": "system", "content": SYSTEM},4{"role": "user", "content": user_text},5]67for step in range(max_steps):8response = client.chat.completions.create(9model=MODEL, messages=messages, tools=SCHEMAS, tool_choice="auto"10)11message = response.choices[0].message12messages.append(message)1314if not message.tool_calls:15return message.content or ""1617for call in message.tool_calls:18name = call.function.name19args = json.loads(call.function.arguments or "{}")20if name not in TOOLS:21result = {"ok": False, "error": f"unknown tool: {name}"}22else:23try:24result = TOOLS[name](**args)25except TypeError as exc:26result = {"ok": False, "error": f"bad arguments: {exc}"}27messages.append(28{29"role": "tool",30"tool_call_id": call.id,31"content": json.dumps(result, ensure_ascii=False),32}33)3435return "达到最大步数,已停止。"
agent.py 第 146 到 180 行。TypeScript 版在 agent.ts 第 152 到 175 行,是同一个形状,换成了 await client.chat.completions.create。
这 27 行里真正在「做事」的只有四件事:
| 行 | 动作 | 为什么必须在这里 |
|---|---|---|
| 153 | 把 messages 和 tools 一起发给模型 | 工具定义是请求的一部分,不是外部配置 |
| 157 | 把模型回复原样 append 回 messages | 下一轮必须看到自己上一轮的 tool_calls,否则协议不成立 |
| 159 | 没有 tool_calls 就结束 | 这是唯一的正常出口 |
| 162 | 逐个执行工具,把结果包成 role: "tool" 追加回去 | 模型需要 tool_call_id 才能把结果和请求对上 |
剩下的是防御:name not in TOOLS 挡住模型编造的函数名,except TypeError 挡住参数不匹配。这两条加起来 6 行,是全文最不该省的地方——模型编造工具名和填错参数是常态,不是异常。
把上面 27 行拆开看,它在做的事只有一件:在「模型想调工具」和「工具返回了什么」之间来回搬运。
那么判断标准就出来了:一段逻辑如果需要「模型的判断」才能决定下一步,它属于循环内;如果不需要,它属于循环外。
拿退款来说:
| 逻辑 | 归属 | 理由 |
|---|---|---|
| 判断用户想查订单还是想退款 | 循环内 | 需要理解自然语言 |
| 从这句话里抽出订单号 A1001 | 循环内 | 同上 |
| 判断订单是否在 15 天退货窗口内 | 循环外 | 日期减法,代码算得比模型准 |
| 决定退多少钱 | 循环外 | 金额上限是业务规则,不是判断 |
| 决定超限时交回给人 | 循环外 | 阈值写死,不交给模型权衡 |
| 用中文把拒绝理由讲清楚 | 循环内 | 这是表达,不是决策 |
这张表里最容易被做错的是最后三行。我见过不少实现把「金额超过 20000 分要不要人工确认」写进 system prompt,让模型自己判断。那等于把一条可以用一个 if 表达的业务规则,换成了一次概率采样。
agent.py 第 55 到 58 行是同一个判断的代码版本:
content/_code/minimal-agent/agent.py
1# 超过自动退款额度,交回给人,不自己决定。2if amount_cents > _MAX_AUTO_REFUND_CENTS:3return {"ok": False, "needs_human": True, "error": "amount exceeds auto-refund ceiling"}
_MAX_AUTO_REFUND_CENTS = 20000 写在第 37 行。这个常量改动一次,全系统生效;写在 prompt 里的话,改动一次要重新验证模型还会不会遵守。
模型能看见的只有 SCHEMAS 那一段。它看不见 get_order 的函数体,看不见 _ORDERS 字典,也看不见注释。
所以工具描述写什么,直接决定模型调得准不准。Anthropic 在上下文工程那篇里把这条讲得更直接:工具定义的是 Agent 与信息空间之间的契约,如果一个人工程师都无法确定某个场景该用哪个工具,就不能指望 AI Agent 做得更好。
我写 SCHEMAS(第 92 到 139 行)时用的是三个约定:
- 描述写「什么时候用它」,不写「它能做什么」。
check_refund_policy的描述是「判断订单是否还在退货窗口内,并给出可退金额。在决定能不能退款之前先调它」,而不是「查询退款政策」。后半句是调度指令,前半句是能力说明。 - 顺序约束写进描述。
refund_order的描述里有一句「只允许在 check_refund_policy 返回 in_window=true 之后调用」。这条约束同时也在代码里兜底(窗口外订单会被refund_order自己拒掉),描述只是让模型少走一次弯路。 - 参数描述带例子。
order_id的 description 是「订单号,例如 A1001」。加了这四个字之后,模型不再自己发明形如ORD-1001的编号。
第三条的收益和代价要放在一起说:好处是命中率上升,代价是你得维护这个例子。订单号格式一旦从 A1001 变成 A-1001,描述里那个例子就变成了误导。工具描述是有生命周期的代码,不是一次写完的文档。
Agent 需要记住上一轮发生了什么。常见做法是上向量库,做长期记忆检索。
这个 demo 没这么做。第 147 到 150 行,历史就是一个 list:
content/_code/minimal-agent/agent.py
1messages = [2{"role": "system", "content": SYSTEM},3{"role": "user", "content": user_text},4]
理由很实际。这个 Agent 需要记住的东西有三类:这轮对话里说过什么(messages 里就有)、订单现在是什么状态(关系库里有)、上次退款是谁批的(审计表里有)。三类都不需要语义检索。
向量记忆解决的是「从一大堆非结构化文本里找出语义相近的一段」。当你要记的东西本来就是结构化的——订单号、金额、状态、时间——那用 SELECT ... WHERE order_id = ? 查得又快又准,还没有召回率这个变量。
代价在这里: 只靠 messages 做记忆,对话一长就会撞上上下文窗口。真到了那一步,正确的动作是压缩历史或者把状态外置成结构化字段,而不是先上向量库再想办法让它别乱召回。
前面都在讲省了什么。现在讲代价,这部分不放在文末的「局限性」小节里,因为它是全文信息量最大的部分。
第一,重试和退避归你。 上面 27 行没有任何 try/except 包住 client.chat.completions.create。真实环境里 429 和 5xx 是常态,你得自己写指数退避。框架通常自带这个,砍掉框架就是把这部分工作量搬回你的仓库。
第二,流式和并发归你。 create 换成 stream=True 之后,tool_calls 的解析要自己处理增量分片——参数 JSON 是分多次到达的,拼错了就是解析失败。框架帮你封好的那层,现在需要你读一遍 SDK 的类型定义。
第三,可观测性归你。 上面那段代码只把工具结果 append 进 messages,没有日志、没有耗时、没有 trace。出事的时候你手上只有一个内存里的 list。要定位问题,得自己加埋点。我这份 demo 没加,因为它只有一个文件;你的项目不会只有一个文件。
第四,也是最少被提到的一条:框架解决的是怎么写 Demo,业务代码要解决的是生产事故谁背锅。 框架把工具调用抽象成一个装饰器,读起来很舒服;出事的时候,读这个装饰器源码的人是你。Anthropic 那篇文章的措辞是「如果你确实要用框架,请确保理解底层代码」——把这句话当成前置条件读,而不是免责声明。
所以真正的判断不是「框架好不好」,而是你愿意为哪部分复杂度付费。框架把入门成本压低,把调试成本抬高;裸循环把调试成本压低,把工程配套成本抬高。两者都不便宜,只是账单开在不同月份。
这篇文章不是让你把所有框架都删掉。有三种情况我会选框架:
- 需要跨语言、跨团队统一工具契约。 工具定义要同时被 Python、Go、TypeScript 三个服务消费时,一个公共的 schema 层比三份手写 JSON 靠谱。
- 需要开箱的持久化与人工审批流。 长任务跑几小时、要断点续跑、要人在关键节点点确认,这套基础设施自己写一遍不划算。
- 团队里没人愿意读 SDK 类型定义。 这是个组织问题,不是技术问题,但它真实存在。
反过来,如果你只想要「用户说人话,系统调对函数」,那 27 行够了。
这篇文章发布的时候,模型厂商还在按月改 SDK。下面的代码会过期,Anthropic 那篇文章顶部的编者注已经在提示它的工具生态变了。
但有一件事现在就能确定:Agent 的复杂度不在循环里,在工具契约和业务边界上。 循环的写法三年内会变,工具描述该写「什么时候用它」这件事不会变——因为那是给人看的,也是给模型看的,而人读文档的困难和模型读工具定义的困难,是同一个困难。
剩下的距离,全在定义工具的那一侧。
- Anthropic, Building effective agents,2024-12-19。本文引用的「从直接使用 LLM API 开始」「额外抽象层」「LLM 在循环中基于环境反馈使用工具」均出自此文。
- Anthropic, Effective context engineering for AI agents,2025-09-29。工具设计与「bloated tool sets」的失败模式出自此文。
- HumanLayer, 12-factor-agents。「把控制流留在代码里」这条原则的出处。
- 示例代码:
content/_code/minimal-agent/agent.py(Python)、content/_code/minimal-agent/agent.ts(TypeScript)。仓库为私有,行号供本机核对。