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 在循环中,基于环境反馈使用工具。 不是规划器,不是记忆体,不是多智能体社会。

这篇文章想回答三个可以被验证的问题:

  1. 一个不带任何 Agent 框架的运行时,最少要几行代码?
  2. 哪些逻辑必须留在循环里,哪些必须推到循环外?
  3. 砍掉框架之后,代价具体落在谁头上?

我做不到什么

先把边界划清楚,后面就不必每段都补一句「这只是我的判断」。

  • 我没有在生产流量上跑过下面这份代码。 它在本机跑通过,模型调用是真的,退款逻辑是假的(订单存在内存字典里)。本文不出现任何「性能提升 X%」,因为我没有做过框架与裸循环的对照实验。
  • 官方数字是官方发布的。 Anthropic 的原文、OpenAI 的函数调用文档,我都贴了链接;我只做了把它们放在一起核对这件事。
  • 我给的代码只有 Python 和 TypeScript 两个版本。 其余语言的 SDK 结构一样,我没有逐个验证,所以不写。
  • 下文每一处代码都标了它在我仓库里的真实路径和行号,方便你按行核对。仓库是私有的,所以那些路径只是路标,不是可点的外链。

27 行的运行时

先看结论。整个 Agent 运行时就是下面这个函数,35 行,其中循环体 27 行。

content/_code/minimal-agent/agent.py

1
def run(user_text: str, max_steps: int = 8) -> str:
2
messages = [
3
{"role": "system", "content": SYSTEM},
4
{"role": "user", "content": user_text},
5
]
6
7
for step in range(max_steps):
8
response = client.chat.completions.create(
9
model=MODEL, messages=messages, tools=SCHEMAS, tool_choice="auto"
10
)
11
message = response.choices[0].message
12
messages.append(message)
13
14
if not message.tool_calls:
15
return message.content or ""
16
17
for call in message.tool_calls:
18
name = call.function.name
19
args = json.loads(call.function.arguments or "{}")
20
if name not in TOOLS:
21
result = {"ok": False, "error": f"unknown tool: {name}"}
22
else:
23
try:
24
result = TOOLS[name](**args)
25
except TypeError as exc:
26
result = {"ok": False, "error": f"bad arguments: {exc}"}
27
messages.append(
28
{
29
"role": "tool",
30
"tool_call_id": call.id,
31
"content": json.dumps(result, ensure_ascii=False),
32
}
33
)
34
35
return "达到最大步数,已停止。"

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
# 超过自动退款额度,交回给人,不自己决定。
2
if amount_cents > _MAX_AUTO_REFUND_CENTS:
3
return {"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 行)时用的是三个约定:

  1. 描述写「什么时候用它」,不写「它能做什么」。 check_refund_policy 的描述是「判断订单是否还在退货窗口内,并给出可退金额。在决定能不能退款之前先调它」,而不是「查询退款政策」。后半句是调度指令,前半句是能力说明。
  2. 顺序约束写进描述。 refund_order 的描述里有一句「只允许在 check_refund_policy 返回 in_window=true 之后调用」。这条约束同时也在代码里兜底(窗口外订单会被 refund_order 自己拒掉),描述只是让模型少走一次弯路。
  3. 参数描述带例子。 order_id 的 description 是「订单号,例如 A1001」。加了这四个字之后,模型不再自己发明形如 ORD-1001 的编号。

第三条的收益和代价要放在一起说:好处是命中率上升,代价是你得维护这个例子。订单号格式一旦从 A1001 变成 A-1001,描述里那个例子就变成了误导。工具描述是有生命周期的代码,不是一次写完的文档。

状态:不要为了「记忆」引入第二套存储

Agent 需要记住上一轮发生了什么。常见做法是上向量库,做长期记忆检索。

这个 demo 没这么做。第 147 到 150 行,历史就是一个 list:

content/_code/minimal-agent/agent.py

1
messages = [
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」的失败模式出自此文。
  • OpenAI, Function calling。tool_calls、tool_call_id 的协议结构以本文档为准。
  • HumanLayer, 12-factor-agents。「把控制流留在代码里」这条原则的出处。
  • 示例代码:content/_code/minimal-agent/agent.py(Python)、content/_code/minimal-agent/agent.ts(TypeScript)。仓库为私有,行号供本机核对。

Share this note on Twitter / X · Browse all articles · Feedback via @qianyuhe.

Thanks for reading.

– 千羽鹤