引言
AI Agent 的早期实现几乎都是「一个循环 + 一堆工具」:让模型决定下一步调什么工具,执行后再把结果喂回去,直到模型认为任务完成。这个模式在演示里很惊艳,在生产里很快暴露三个问题:循环可能不收敛(一直调用工具)、失败后无法恢复(进程重启就丢状态)、成本不可控(模型反复思考同一件事)。
工作流引擎恰好能解决这三个问题。它提供确定性的骨架(哪些步骤、什么顺序、失败怎么办),把模型的非确定性限制在特定的步骤里。这样既保留了模型的灵活性,又获得了可恢复、可观测、可控制的执行过程。
但两者结合有一个根本矛盾:工作流引擎(尤其是持久化执行引擎)要求代码确定性,而 LLM 调用本质上是不确定的。理解这个矛盾的处理方式,是用好「工作流 + Agent」的关键。
本文先讲确定性与非确定性的边界怎么划,再对比 Agent 循环与工作流编排的适用场景,然后讲工具调用、持久化执行、人工审批、成本控制、多 Agent 协作、降级与评估,最后讲安全与可观测。想先理解持久化执行的机制,可以从 Temporal 与持久化执行 开始。
目录
- 为什么 Agent 需要工作流
- 确定性与非确定性的边界
- Agent 循环与工作流编排的取舍
- 工具调用与副作用隔离
- 持久化执行下的 LLM 步骤
- 人工介入的审批节点
- 成本与延迟控制
- 非确定性步骤的幂等
- 多 Agent 协作的编排
- 失败重试与模型降级
- 评估与回归测试
- 提示词与配置的版本化
- 安全与权限边界
- 可观测:token、成本与质量
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 为什么 Agent 需要工作流
一个纯 Agent 循环在生产环境会遇到四类问题,而这四类恰好是工作流引擎的强项:
| 问题 | 纯 Agent 循环 | 工作流引擎 |
|---|---|---|
| 状态丢失 | 进程重启后从头开始 | 事件历史持久化,断点恢复 |
| 循环不收敛 | 模型可能无限调用工具 | 步骤数有上限,超时可控 |
| 成本不可控 | 每轮都重新思考 | 步骤边界清晰,可缓存与复用 |
| 不可审计 | 只有对话日志 | 每步的输入输出结构化记录 |
其中「循环不收敛」是最致命的问题。模型可能因为工具返回的错误信息不断尝试、或者陷入「再确认一次」的循环。工作流引擎的解法是「用代码控制循环」:for 循环的迭代次数由代码决定,模型只在循环体内做决策。
另一个重要原因是「人在回路」。Agent 执行敏感操作(发邮件、改数据、转账)前需要人工确认,而纯 Agent 循环里插入人工确认很别扭。工作流引擎里这只是一个「等待信号」的步骤。
2. 确定性与非确定性的边界
划分边界的原则是:能用代码确定的就用代码,只有真正需要判断的才交给模型。
确定性(工作流控制) 非确定性(模型决策)
--------------------------- ---------------------------
步骤顺序与分支条件 自然语言理解与意图识别
重试与超时策略 内容生成与摘要
数据校验与格式转换 工具选择的优先级判断
权限与安全校验 模糊输入的分类
持久化与幂等 开放式问题的回答
一个常见的反模式是「让模型决定流程分支」:
# 反模式:让模型决定下一步走哪个节点
next_step = llm.invoke(f"根据订单 {order} 决定下一步是 A 还是 B")
if next_step == "A":
do_a()
这个设计的三个问题:模型的输出不稳定(同样的输入可能给出不同答案)、不可测试(无法写确定性的单元测试)、成本高(每次分支判断都要调用模型)。
正确做法是用规则判断分支,只在「需要理解自然语言」的地方调用模型:
# 正例:规则判断分支,模型只做内容理解
category = classify_by_rules(order) # 确定性
if category == "NEEDS_REVIEW":
summary = llm.summarize(order.raw_text) # 非确定性,但边界清晰
3. Agent 循环与工作流编排的取舍
两种模式各有适用场景:
| 维度 | Agent 循环 | 工作流编排 |
|---|---|---|
| 流程可预测性 | 低(模型决定) | 高(代码决定) |
| 灵活性 | 高(可处理未预期情况) | 低(只走定义好的路径) |
| 可测试性 | 差(输出不确定) | 好(步骤可独立测试) |
| 成本 | 高(每轮都推理) | 低(只有必要步骤调模型) |
| 可审计 | 差 | 好 |
| 适用任务 | 开放式探索、研究类 | 业务流程、审批、数据管道 |
实用的判断标准是「这个任务的成功标准是否明确」。如果成功标准明确(比如「生成一份符合模板的报告」),用工作流编排更可靠;如果成功标准模糊(比如「帮我研究这个市场」),Agent 循环更合适。
一个折中方案是「工作流包裹 Agent 循环」:外层用工作流控制步骤(准备数据、执行 Agent、校验结果、人工确认、归档),内层用受限的 Agent 循环处理需要探索的部分。这样外层获得了可靠性与可观测性,内层保留了灵活性。
@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> Report:
docs = await workflow.execute_activity(fetch_docs, topic) # 确定性
draft = await workflow.execute_activity(run_agent_loop, docs) # 内层 Agent 循环
await workflow.execute_activity(validate_report, draft) # 确定性校验
approved = await workflow.wait_condition(lambda: self.approved) # 人工确认
return await workflow.execute_activity(archive, draft)
4. 工具调用与副作用隔离
Agent 的工具分两类:只读工具(查询、检索、计算)与写工具(发消息、改数据、下单)。两者的处理方式完全不同。
只读工具可以随意重试,因为它们没有副作用。写工具必须幂等,且应该有审批门槛。
TOOLS = {
# 只读工具:可重试,无副作用
"search_docs": Tool(fn=search_docs, read_only=True),
"query_db": Tool(fn=query_db, read_only=True),
# 写工具:必须幂等,需要审批
"send_email": Tool(fn=send_email, read_only=False, requires_approval=True),
"create_ticket": Tool(fn=create_ticket, read_only=False, requires_approval=True),
}
在工作流里,写工具应该被包装成独立的步骤,并带上幂等键:
@activity.defn
async def send_email(order_id: str, recipient: str, body: str) -> str:
# 幂等键由工作流生成并传入,重试时不变
idem_key = f"email:{order_id}"
if await email_log.exists(idem_key):
return await email_log.get_result(idem_key)
msg_id = await smtp.send(recipient, body)
await email_log.save(idem_key, msg_id)
return msg_id
另一个重要的设计是「工具白名单」:不要让模型自由选择任意工具,而是按步骤限定可用工具集。比如「生成摘要」的步骤只给检索工具,「执行操作」的步骤才给写工具。这既降低了误操作风险,也让提示词更短更聚焦。
5. 持久化执行下的 LLM 步骤
在 Temporal 这类持久化执行引擎里,LLM 调用必须放在 Activity 里,因为它是非确定性的(同样的输入可能返回不同的输出,且调用有成本)。
// 反模式:在 Workflow 里直接调 LLM
public void execute(TaskInput input) {
String result = llmClient.complete(input.prompt()); // 破坏确定性
activities.save(result);
}
// 正例:LLM 调用在 Activity 里
public void execute(TaskInput input) {
String result = activities.callLlm(input.prompt());
activities.save(result);
}
Activity 的返回值会被写进事件历史,重放时直接读取历史而不重新调用模型。这带来两个好处:重放不重复计费;重放的输出与首次一致,保证了确定性。
代价是「历史膨胀」:LLM 的输出通常很长(几百到几千 token),写进事件历史会让历史迅速变大。解决方案是「Activity 返回引用而不是内容」:
@ActivityMethod
public String callLlm(String prompt) {
String content = llmClient.complete(prompt);
String key = blobStore.put(content); // 大内容存对象存储
return key; // 历史里只存 key
}
6. 人工介入的审批节点
Agent 的写操作应该有人工审批门槛,尤其是「不可逆」的操作。在工作流里,这是一个「等待信号」的步骤。
@workflow.defn
class AgentWithApproval:
def __init__(self):
self.approved = None
@workflow.signal
def approve(self, decision: bool, comment: str = ""):
self.approved = (decision, comment)
@workflow.run
async def run(self, task: str):
plan = await workflow.execute_activity(agent_plan, task)
await workflow.execute_activity(notify_reviewer, plan)
# 最多等 24 小时,超时视为拒绝
ok = await workflow.wait_condition(
lambda: self.approved is not None,
timeout=timedelta(hours=24))
if not ok or not self.approved[0]:
return {"status": "REJECTED"}
result = await workflow.execute_activity(agent_execute, plan)
return {"status": "DONE", "result": result}
审批的内容设计很关键:审批人需要看到「Agent 打算做什么、为什么、影响范围」,而不是「是否同意」这一个按钮。建议把 Agent 的计划(工具调用序列与参数)结构化展示,让审批人能判断而不是盲签。
审批的粒度也要设计:每个操作都审批会让 Agent 失去价值(人成了瓶颈),完全不审批则风险不可控。实用做法是「按风险分级」:只读操作自动放行,写操作按影响范围决定是否需要审批(比如「发送内部通知」自动,「发送外部邮件」审批)。
7. 成本与延迟控制
LLM 步骤的成本与延迟是 Agent 工作流里最不可预测的部分。四种控制手段:
- 缓存:相同输入直接返回缓存结果。注意缓存键要包含模型版本与提示词版本。
- 模型分级:简单步骤用小模型,复杂步骤用大模型。
- 步骤预算:给每个步骤设 token 上限与超时,超限则降级或失败。
- 提前终止:设置总步数上限与总 token 预算,超过则终止并返回部分结果。
agent_workflow:
total_budget:
max_steps: 20
max_tokens: 200000
max_duration: 10m
steps:
classify:
model: gpt-4o-mini
max_tokens: 200
cache: true
summarize:
model: gpt-4o
max_tokens: 4000
cache: true
execute:
model: gpt-4o
max_tokens: 8000
cache: false # 有副作用的步骤不缓存
「有副作用的步骤不缓存」是一条重要规则:缓存一个「发送邮件」的步骤会导致「第一次发了,第二次不发了」,语义完全错乱。缓存只适用于纯计算步骤。
8. 非确定性步骤的幂等
LLM 调用本身是幂等的(同样的输入得到同样的输出,除非有随机性),但它的下游副作用不是。所以幂等设计的重点是「基于 LLM 输出的副作用」。
三种做法:
# 做法一:把 LLM 输出先持久化,副作用基于持久化结果执行
analysis = await workflow.execute_activity(llm_analyze, doc) # 结果进历史
# 重放时直接读历史,不会重新分析,因此后续副作用也不会重复
# 做法二:副作用带幂等键
await workflow.execute_activity(
send_notification,
NotificationArgs(idem_key=f"notify:{task_id}", content=analysis))
# 做法三:把 LLM 输出当决策,用状态前置条件保护
await workflow.execute_activity(
apply_decision,
DecisionArgs(task_id=task_id, decision=analysis.decision))
# apply_decision 内部用「WHERE status='PENDING'」保证只生效一次
做法一是持久化执行引擎的天然优势:因为 LLM 的输出进了事件历史,重放时不会重新调用模型,所以整个链路是确定的。做法二与做法三适用于非持久化引擎。
要特别注意「LLM 输出被解析成结构化数据」的场景:如果模型输出 JSON 但格式不合法,解析会失败。这类失败应该重试(让模型重新生成),但重试会消耗成本,所以要有次数上限并配合更明确的提示词。
9. 多 Agent 协作的编排
多 Agent 协作有两种组织方式,与 Saga 的编排/协同之分类似。
编排式(Orchestrator-Workers):一个主 Agent 负责任务分解与结果汇总,多个 Worker Agent 执行子任务。
@workflow.defn
class OrchestratorWorkflow:
@workflow.run
async def run(self, goal: str):
subtasks = await workflow.execute_activity(plan_subtasks, goal)
# 并行执行子任务
handles = [
workflow.start_child_workflow(
WorkerWorkflow.run, st, id=f"{goal}-{i}")
for i, st in enumerate(subtasks)
]
results = [await h for h in handles]
return await workflow.execute_activity(synthesize, results)
协同式(Peer-to-Peer):Agent 之间直接传递消息,没有中心协调者。这种方式灵活但难以追踪,容易出现「两个 Agent 互相等待」的死锁。
实践建议:生产系统优先用编排式,因为「谁在做什么」是可见的;协同式只适合探索性场景。子工作流(Child Workflow)是编排式的基础设施,每个子任务有独立的历史与状态,可以单独重试与观测。
并行执行子任务时要注意「一个失败是否取消其他」:如果子任务之间有依赖,一个失败应该取消其他(避免浪费成本);如果相互独立,可以让它们各自完成再汇总。
10. 失败重试与模型降级
LLM 调用的失败模式比普通 API 更多:
| 失败类型 | 例子 | 处理策略 |
|---|---|---|
| 限流 | 429 Too Many Requests | 退避重试 |
| 超时 | 请求超过 60 秒 | 重试或降级到小模型 |
| 内容过滤 | 被安全策略拦截 | 不重试,转人工或改写提示词 |
| 格式错误 | 输出不是合法 JSON | 重试并强化格式约束 |
| 能力不足 | 输出质量不达标 | 升级到大模型或转人工 |
| 上下文超限 | 输入超过模型窗口 | 截断或分段处理 |
@activity.defn
async def llm_with_fallback(prompt: str) -> str:
for model in ["gpt-4o", "gpt-4o-mini", "local-model"]:
try:
return await call_llm(model, prompt)
except RateLimitError:
await asyncio.sleep(backoff())
except (TimeoutError, ServiceUnavailable):
continue # 降级到下一个模型
except ContentFilterError:
raise # 内容问题不降级,直接上抛
raise AllModelsFailed("所有模型均不可用")
降级的顺序应该是「能力从高到低」,且降级后要记录「本次用了降级模型」,因为输出质量可能不同。这与 重试幂等与补偿设计 里的错误分类是同一条原则:可自愈的错误重试,不可自愈的错误上抛。
「内容过滤」这类错误不要重试:重试同样的提示词大概率还是被拦,应该转人工或修改提示词后重试。
11. 评估与回归测试
Agent 工作流最大的工程挑战是「怎么知道改动没有让效果变差」。传统单元测试不适用(输出不确定),需要评估集(Eval Set)。
# 评估集:输入 + 期望的关键特征(不是精确输出)
EVAL_CASES = [
{"input": "退款申请:订单 1001,原因商品破损",
"expect": {"category": "REFUND", "requires_approval": True}},
{"input": "咨询:什么时候发货",
"expect": {"category": "INQUIRY", "requires_approval": False}},
]
async def run_eval(pipeline) -> EvalReport:
results = []
for case in EVAL_CASES:
out = await pipeline(case["input"])
results.append({
"case": case,
"passed": matches(out, case["expect"]),
})
return EvalReport(results)
评估集的设计要点:断言「关键特征」而不是「精确输出」。比如断言「分类正确」「是否触发审批」,而不是断言「生成的文本完全一致」。这样既稳定又有意义。
评估要进 CI,且要有「回归门禁」:关键用例的通过率低于阈值则阻塞发布。这是 Agent 系统能持续迭代的前提,否则每次改提示词都是赌博。
12. 提示词与配置的版本化
提示词是代码,必须版本化。三个层次:
- 提示词模板放进 Git,走 PR 评审。
- 每次发布带版本号,工作流实例记录使用的是哪个版本。
- 支持灰度与回滚,出问题能快速切回。
# prompts/refund-classify/v3.yaml
version: v3
model: gpt-4o-mini
temperature: 0
system: |
你是退款申请分类器。只输出 JSON,不要解释。
字段:category(REFUND/INQUIRY/COMPLAINT)、requires_approval(bool)
user: |
申请内容:{{ text }}
订单金额:{{ amount }}
在工作流里记录版本,便于事后追溯「这个实例用的是哪版提示词」:
result = await workflow.execute_activity(
llm_classify,
ClassifyArgs(text=text, prompt_version="refund-classify/v3"))
13. 安全与权限边界
Agent 的安全风险主要来自「模型被诱导执行不该执行的操作」(提示词注入)。防御手段有四层:
- 工具白名单:每个步骤只暴露必要的工具,模型无法调用未授权的工具。
- 参数校验:工具的参数在服务端校验,不信任模型给出的值(比如「删除用户」的 ID 必须在允许列表内)。
- 权限收敛:Agent 使用的凭证权限最小化,比如只能读特定表、只能发特定域名。
- 人工审批:不可逆操作强制人工确认。
def execute_tool(name: str, args: dict, allowed: set[str], principal: str):
if name not in allowed:
raise PermissionError(f"tool {name} not allowed in this step")
tool = TOOLS[name]
validated = tool.schema.validate(args) # 参数结构校验
if not tool.authorize(principal, validated): # 业务权限校验
raise PermissionError(f"principal {principal} cannot {name}")
return tool.fn(validated)
提示词注入的典型场景是「Agent 读取了外部内容(网页、邮件、文档),内容里藏着指令」。防御的核心是「不把外部内容当指令」:在提示词里明确区分「数据」与「指令」,并在服务端做权限校验而不是依赖模型自觉。
14. 可观测:token、成本与质量
Agent 工作流的观测比普通工作流多三个维度:
# 每个步骤的 token 消耗
agent_tokens_total{workflow="RefundFlow", step="classify", model="gpt-4o-mini", direction="input"} 125000
agent_tokens_total{workflow="RefundFlow", step="classify", model="gpt-4o-mini", direction="output"} 8000
# 每个工作流实例的成本
agent_cost_usd_sum{workflow="RefundFlow"} 42.17
# 质量指标
agent_eval_pass_rate{workflow="RefundFlow", eval_set="core"} 0.94
agent_fallback_total{workflow="RefundFlow", from="gpt-4o", to="gpt-4o-mini"} 12
agent_human_review_total{workflow="RefundFlow", reason="content_filter"} 3
「按工作流类型统计成本」是最实用的成本视图:它能回答「处理一个退款申请平均花多少钱」,从而判断这个 Agent 是否值得上线。
工作流级的可观测(实例查询、事件历史、停滞告警)与普通工作流一致,参考 工作流可观测与调试 。
15. 落地路线图
- 第 1 周:把一个确定性的流程(比如「分类 → 生成草稿 → 人工确认 → 发送」)用工作流引擎实现,LLM 步骤作为 Activity。
- 第 2 周:加入幂等键与缓存,验证「重放不重复调用模型、不重复产生副作用」。
- 第 3 周:加入成本预算与模型降级,配置 token 与步数上限。
- 第 4 周:建立评估集与 CI 门禁,接入 token 与成本指标。
不要从「完全自主的 Agent」开始。先做一个「工作流 + 少量 LLM 步骤」的系统,把可靠性基础打牢,再逐步把更多的决策交给模型。这样每一步的复杂度都是可控的。
16. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 工作流编排 Agent 循环 | 可靠、可观测、可审计 | 灵活性下降,需要预先定义步骤 |
| 纯 Agent 循环 | 灵活,能处理未预期情况 | 不收敛风险,成本不可控 |
| LLM 步骤放 Activity | 重放不重复计费,输出稳定 | 历史膨胀,需要存引用 |
| 大模型直出 | 质量最好 | 成本高、延迟大 |
| 模型分级 + 降级 | 成本可控 | 质量不稳定,需要评估兜底 |
| 结果缓存 | 成本显著下降 | 需要版本化缓存键 |
| 全自动执行 | 效率最高 | 不可逆操作风险高 |
| 提示词进 Git | 可版本化、可回滚 | 迭代速度受发版流程限制 |
17. 常见坑清单
- 让模型决定流程分支,输出不稳定导致行为不可复现,应该用规则判断。
- 在持久化执行的 Workflow 里直接调 LLM,破坏确定性导致重放失败。
- LLM 的长输出直接进事件历史,历史膨胀到几十 MB,重放极慢。
- LLM 步骤的重试重新生成输出,导致下游副作用与首次不一致。
- 有副作用的步骤开启缓存,出现「第一次执行了、第二次不执行」。
- 缓存键不含模型版本与提示词版本,模型升级后返回过时结果。
- 不设 token 与步数预算,一个失控的 Agent 循环烧掉大量成本。
- 内容过滤错误被无限重试,同样的提示词反复被拦。
- 写操作没有幂等键,工作流重试导致重复发邮件、重复下单。
- 工具权限过大(能删任意数据),提示词注入后造成严重破坏。
- 提示词直接写在代码里且没有版本号,出问题无法回滚到上一版。
- 只测「能跑通」,没有评估集与回归门禁,改提示词靠感觉。
18. 小结
工作流与 AI Agent 的结合,本质是「用确定性的骨架包裹非确定性的步骤」。骨架负责顺序、重试、幂等、审批、可观测;模型负责理解与生成。边界划得越清楚,系统越可靠,也越容易评估与优化。
三条落地原则:能用代码确定的绝不交给模型;LLM 调用必须放在有持久化与幂等保护的步骤里;写操作必须有权限校验与审批门槛。这三条做到了,Agent 工作流就能从演示走向生产。
下一步建议读 重试幂等与补偿设计 把 LLM 步骤的幂等做扎实,读 人工任务与审批流表单 把审批节点的体验与粒度设计好;如果 Agent 的写操作涉及跨服务回滚,则应该结合 Saga 与分布式事务补偿 一起设计补偿链路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。