2765 字
约 9 分钟
21
S20-X12 · Agent 的规划与工具编排

"接一个 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. 自测

  1. 什么样的任务本来不该用 Agent,而应该写死成 workflow?
  2. ReAct 与"计划-执行-反思"的主要差别在计划何时确定,各自的风险是什么?
  3. 循环终止的三道保障分别防的是什么失控?
  4. 为什么工具数量增加会让模型选错的概率上升,工程上怎么缓解?
  5. 重试带副作用的工具前,必须先做什么?
  6. 为什么不能只靠模型自己声明"任务已完成"?
S20-X12 · Agent 的规划与工具编排
https://www.clxhxhhr.top/posts/5890/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。