"接一个 LLM 就能自动干活"的期待,通常在第一次遇到需要三步以上、且第二步结果决定第三步动作的任务时破灭。单轮问答是"输入→输出",Agent 是"输入→动作→观察→再决定"。差别不在模型,在控制流。
1. Agent 与一次问答的分界
| 判据 | 一次问答够用 | 必须上 Agent |
|---|---|---|
| 步数 | 一步能答(含检索) | 多步且步数事先不确定 |
| 外部状态 | 只读知识 | 要查库、写数据、调接口 |
| 分支 | 无分支 | 结果决定下一步动作 |
| 失败处理 | 一次重试 | 换路径、换工具、重规划 |
| 成本 | 可预估 | 波动大,需硬上限 |
判据一句话:动作序列能不能事先写死。能写死就走 workflow,别请模型"自主决策"。
2. 三种编排形态
| 形态 | 谁决定下一步 | 可预测性 | 成本 | 适合 |
|---|---|---|---|---|
| workflow | 代码写死的 DAG | 高 | 低且稳定 | 步骤固定的流水线(抽取→校验→入库) |
| agent | 模型每步自选 | 低 | 波动大 | 路径依赖外部结果、无法穷举 |
| 有限自主 | 代码定骨架,模型填局部 | 中 | 可控 | 大多数生产场景 |
有限自主是默认答案:外层状态机固定阶段,内层只让模型决定"选哪个工具、怎么填参数",每个阶段可单独设超时、重试和预算。
// 骨架固定:阶段由代码推进,模型只在阶段内选动作
enum Stage { PLAN, EXECUTE, REFLECT, DONE }
Stage stage = Stage.PLAN;
while (stage != Stage.DONE && steps++ < MAX_STEPS) {
stage = switch (stage) {
case PLAN -> planner.plan(goal); // 模型产出任务列表
case EXECUTE -> executor.step(state); // 模型选工具 + 填参数
case REFLECT -> checker.verify(state); // 模型判定是否达成
default -> Stage.DONE;
};
}
3. 规划的实现
ReAct 循环:Thought → Action → Observation 反复。它的价值是把"推理"和"取数"交替,避免模型凭空猜事实。
Thought: 需要先知道订单状态
Action: order_query(order_id="SO20261109001")
Observation: {"status": "已发货", "carrier": "SF"}
Thought: 需要物流节点
Action: logistics_track(tracking_no="SF123")
Observation: {"latest": "已签收"}
Final: 订单已发货并签收。
计划-执行-反思:先产出一份完整计划,再逐步执行,最后统一检查。适合步骤多但中途少变的任务;计划一旦过时,后面全跑偏。
{
"goal": "生成上月退款率周报并发布",
"tasks": [
{"id": 1, "tool": "sql_query", "args": {"sql": "..."}, "depends_on": []},
{"id": 2, "tool": "chart_render", "args": {"rows_ref": 1}, "depends_on": [1]},
{"id": 3, "tool": "doc_publish", "args": {"draft_ref": 2}, "depends_on": [2]}
]
}
任务分解树:任务可再拆子任务时用,适合长链路。风险是深度失控,必须限制层级。
# 任务分解:限定深度与扇出,超出则降级为单步执行
def decompose(task, depth=0, max_depth=3, max_children=5):
if depth >= max_depth:
return [task]
children = planner.split(task)[:max_children]
return [leaf for c in children for leaf in decompose(c, depth + 1, max_depth, max_children)]
| 形态 | 计划何时定 | 中途变更 | 主要风险 |
|---|---|---|---|
| ReAct | 每步现定 | 随时 | 步数失控、绕圈子 |
| 计划-执行-反思 | 一次性 | 差 | 前期错误假设污染全程 |
| 分解树 | 递归 | 中 | 深度爆炸、重复子任务 |
4. 状态管理
| 状态 | 内容 | 存储 | 生命周期 |
|---|---|---|---|
| 会话状态 | 对话消息、用户偏好 | Redis / 内存 | 会话期 |
| 任务状态 | 阶段、已完成步、待办 | DB / Redis | 任务期 |
| 工具结果缓存 | 工具名+参数 → 结果 | Redis(TTL) | 可跨任务 |
| 计划与轨迹 | 每步输入输出 | 日志/对象存储 | 长期复盘 |
# 每一步落盘,崩溃/超时后可从最近一步恢复
state = {
"task_id": "t-8891",
"stage": "EXECUTE",
"steps": [{"i": 3, "tool": "sql_query", "args_hash": "ab12", "status": "ok"}],
"scratch": {"rows_ref": "s3://tmp/ab12.parquet"},
"budget": {"tokens_used": 0, "steps_used": 3, "max_steps": 15},
}
redis.setex(f"agent:{state['task_id']}", 3600, json.dumps(state))
中间结果别只留在上下文里:上下文会被压缩截断,大结果落盘拿引用,只把引用与摘要放进去。
5. 循环终止的三种保障
缺任何一条都会"跑到天亮"。
def should_stop(state, last_observations):
if state["budget"]["steps_used"] >= state["budget"]["max_steps"]:
return True, "max_steps"
if state["stage"] == "DONE":
return True, "completed"
if no_progress(last_observations, window=3): # 连续 3 步观察结果无变化
return True, "no_progress" # 相同工具+相同参数重复调用
if state["budget"]["tokens_used"] >= TOKEN_CAP:
return True, "budget"
return False, None
| 保障 | 触发条件 | 缺失后果 |
|---|---|---|
| 最大步数 | steps >= N | 无限循环烧钱 |
| 完成判定 | 有明确 done 信号 | 明明完成还在继续 |
| 无进展检测 | 连续 N 步工具+参数相同或结果相同 | 反复调用同一工具 |
| 预算上限 | token/费用超阈值 | 单请求花掉整月额度 |
"完成判定"最难写:别依赖模型自称"我完成了",更稳的是结构化结果校验——输出满足 schema 且关键字段非空才算完成。
6. 工具集设计
工具数量与选择准确率负相关:可选越多,选错越多。做法是按阶段收窄工具集——检索阶段只暴露检索类,写入阶段只暴露写入类。具体关系按所用模型的 tool-use 表现实测。
| 设计点 | 反例 | 正例 |
|---|---|---|
| 数量 | 一次给 30 个 | 按阶段给 3–6 个 |
| 命名 | do_stuff |
refund_create |
| 描述 | "处理退款" | 写清何时用/不用、参数单位 |
| 原子性 | fix_order 内部做 5 件事 |
拆成查询/改状态/通知 |
| 返回 | 返回整张表 | 摘要 + 可引用 ref |
工具描述是模型唯一的"说明书"。必须写清:何时调用、何时不要调用、参数类型与单位、返回结构、失败错误码。
{
"name": "refund_query",
"description": "查询单笔退款的当前状态与预计到账时间。仅在用户提供订单号时调用;不要用它查询退款政策(用 policy_search)。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号,形如 SO+日期+序号"},
"with_timeline": {"type": "boolean", "description": "是否返回节点流水,默认 false"}
},
"required": ["order_id"]
}
}
7. 错误恢复
| 失败类型 | 特征 | 策略 |
|---|---|---|
| 瞬时失败 | 超时、连接重置 | 指数退避重试,限次数 |
| 参数错误 | 校验失败、400 | 结构化错误回灌,让模型改参数 |
| 权限/业务拒绝 | 403、业务码 | 不重试,换路径或澄清 |
| 结果为空 | 200 但空集 | 改参数或换工具,别原样重试 |
MAX_RETRY = 3
for attempt in range(MAX_RETRY):
resp = call_tool(name, args)
if resp.ok:
break
if resp.code in RETRYABLE_CODES:
time.sleep(0.5 * 2 ** attempt)
continue
# 不可重试:把错误原因作为观察结果回灌,让模型换路径
return {"tool_error": {"code": resp.code, "message": resp.msg, "hint": "参数或权限问题,勿原样重试"}}
重试必须带幂等键,否则"重试"变成"重复下单"。带副作用的工具要么支持幂等键,要么只允许人工确认后执行。
8. 人在环
| 步骤 | 是否必须确认 | 理由 |
|---|---|---|
| 只读查询 / 生成草稿 | 否 | 幂等、无副作用 |
| 对外发送(邮件、消息) | 是 | 不可撤回 |
| 资金 / 额度变更 | 是 | 不可逆 |
| 删除数据 / 权限变更 | 是 | 影响面大 |
实现:工具标记 requires_confirmation: true,执行到该步挂起并返回待确认项,用户确认后从持久化状态继续。
9. 可观测
{
"trace_id": "tr-77c1",
"step": 4,
"stage": "EXECUTE",
"thought": "需要查退款节点",
"tool": "refund_query",
"args": {"order_id": "SO20261109001"},
"latency_ms": "<实测>",
"tokens": {"in": "<实测>", "out": "<实测>"}
}
latency_ms 与 tokens 必须来自实际采集,别用估算值——口径不一致比没数据更误导。落盘四件:每步输入、输出、耗时、token。
10. 常见坑
| 坑 | 后果 | 改法 |
|---|---|---|
| 不设步数上限 | 无限循环 | 硬上限 + 无进展检测 |
| 一次暴露太多工具 | 选错工具、参数乱填 | 按阶段收窄工具集 |
| 确定性流程交给模型决策 | 结果不稳定、无法回归测试 | 改 workflow |
| 中间结果只留在上下文 | 重跑从头来、上下文被撑爆 | 落盘 + 引用 |
| 用模型自评判完成 | 提前宣告成功 | 结构化校验字段 |
| 重试无幂等键 | 重复扣款/重复发送 | 幂等键或跳过重试 |
11. 排错
| 现象 | 先查 | 处理 |
|---|---|---|
| Agent 一直转圈 | 轨迹里连续步骤是否同参数 | 加无进展检测、收窄工具集 |
| 选错工具 | 工具描述是否含"何时不要用" | 补描述,减工具数 |
| 同一工具重复调用 | 结果是否被正确解析 | 检查返回结构是否被模型误读 |
| 中途崩溃后重跑很久 | 状态是否持久化 | 每步落盘,支持断点续跑 |
| 成本突然翻倍 | 每步 token 分布 | 定位大结果是否被整段塞进上下文 |
| 完成判定误报 | 校验字段是否必填 | 加非空/格式校验 |
12. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 步骤完全固定 | 普通 workflow / 批处理 | 不需要概率决策 |
| 单次检索问答 | 普通 RAG | Agent 只增成本 |
| 严格审计与回放 | 状态机 + 全量轨迹 | 自由 agent 难证"必然如此" |
| 延迟敏感(秒级) | 单次调用 + 并行工具 | 多步串行必超时 |
| 超长任务(小时级) | 任务队列 + 人工介入点 | 长连接不现实 |
13. 自测
- 什么样的任务本来不该用 Agent,而应该写死成 workflow?
- ReAct 与"计划-执行-反思"的主要差别在计划何时确定,各自的风险是什么?
- 循环终止的三道保障分别防的是什么失控?
- 为什么工具数量增加会让模型选错的概率上升,工程上怎么缓解?
- 重试带副作用的工具前,必须先做什么?
- 为什么不能只靠模型自己声明"任务已完成"?