把工作流改成 Agent 三个月后,我们又改回去了一部分
去年我们的运维平台是一套硬编码的工作流:告警触发 → 按告警类型走固定的排查步骤 → 生成结论 → 通知。今年八月我们把它改造成了自主 Agent,让它自己决定调用什么工具、走几步。
跑了三个月,结论比较复杂:有些场景效果好得出乎意料,有些场景 Agent 明显不如工作流,最后我们把一部分场景改回去了。这篇讲这个决策过程,以及我对 Agent 架构边界的理解。
先说清楚两者的本质区别
很多人把「用 LLM 的系统」都叫 Agent,这个混淆导致很多错误的架构决策。我的判断标准是控制流在谁手里。
| 维度 | 工作流(Workflow) | Agent |
|---|---|---|
| 控制流 | 代码里写死,LLM 只在节点内做处理 | LLM 决定下一步做什么 |
| 步数 | 可预测,路径有限 | 不可预测,可能循环 |
| 可测试性 | 高,固定的输入输出 | 低,非确定性 |
| 单次成本 | 可控(节点数固定) | 方差大 |
| 适合的场景 | 流程已知、要求稳定 | 路径未知、需要探索 |
举个具体例子。我们的「发布后健康检查」是工作流:
// 流程固定,只有 5 步,不需要 LLM 决定下一步
public HealthCheckResult checkAfterDeploy(DeployContext ctx) {
var metrics = collectMetrics(ctx.service(), Duration.ofMinutes(10));
var errors = collectErrorLogs(ctx.service(), Duration.ofMinutes(10));
var traces = sampleTraces(ctx.service(), 100);
var analysis = llm.analyze(CHECK_PROMPT, metrics, errors, traces); // LLM 只在这一个节点
boolean healthy = analysis.score() > 0.7;
if (!healthy) rollbackService.rollback(ctx); // 回滚逻辑是确定性的
notify(ctx.owner(), analysis);
return result;
}
这里 LLM 只负责「分析」这一个环节,整个流程的骨架是代码控制的。这不算 Agent,但它能用,而且非常稳定——跑了 14 个月,零误回滚。
而「排查一个未知原因的故障」就是 Agent 场景:不知道要看哪个服务、要查几个指标、查到什么程度就该停。路径完全依赖中间结果,必须由模型动态决定。
Agent 的核心循环:感知-规划-行动
我们实现的循环结构是这样的:
public class AgentLoop {
private static final int MAX_STEPS = 25;
public TaskResult run(String task, ToolRegistry tools, Memory memory) {
List<Step> history = new ArrayList<>();
for (int step = 0; step < MAX_STEPS; step++) {
// ── 1. 感知:组装当前可见的世界状态 ──
Context ctx = Context.builder()
.systemPrompt(systemPrompt)
.tools(tools.specifications())
.episodicMemory(memory.recall(task)) // 召回相关经验
.history(history)
.build();
// ── 2. 规划+决策:模型输出下一步 ──
ModelResponse resp = model.call(ctx);
history.add(Step.of(resp));
if (resp.isFinalAnswer()) {
return finish(history, resp.answer());
}
// ── 3. 行动:执行工具 ──
ToolCall call = resp.toolCall();
ToolResult result = tools.execute(call); // 含权限校验、沙箱
// ── 4. 观察:把结果放回历史 ──
history.add(Step.of(call, result));
// ── 5. 收敛判断 ──
if (shouldStop(history)) {
return abort(history, stopReason(history));
}
}
return abort(history, "达到最大步数");
}
}
五个环节里,最容易被写坏的是第 1 步(感知)和第 5 步(收敛)。
感知:上下文组装是有策略的
「把历史全塞进去」是最差的做法。我们在前面几篇里详细写过上下文工程,这里只说和循环结构相关的部分——不同阶段该看到的东西不一样。
Context buildContext(Phase phase, List<Step> history) {
return switch (phase) {
// 规划阶段:不需要看到所有历史细节,看到目标和已有结论即可
case PLAN -> ctx(systemPrompt, tools, historySummary(history));
// 行动阶段:需要最近几步的完整细节(工具返回的原文)
case ACT -> ctx(systemPrompt, tools, recentSteps(history, 3),
historySummary(history));
// 总结阶段:需要全部结论,不需要工具返回的原始 JSON
case SUMMARIZE -> ctx(systemPrompt, allConclusions(history));
};
}
这个设计让单任务的平均 token 从 41,000 降到 26,800,而且准确率还提升了 4 个百分点(噪音少了)。
收敛:最难的部分
Agent 最大的风险不是做错,是停不下来。我们统计过没有收敛机制时的步数分布:
| 步数区间 | 占比 | 平均成本 |
|---|---|---|
| 1~5 步 | 41% | ¥0.21 |
| 6~15 步 | 38% | ¥0.58 |
| 16~30 步 | 14% | ¥1.34 |
| >30 步(含死循环) | 7% | ¥4.72 |
7% 的任务消耗了 34% 的成本,其中真正死循环的(在两个工具之间反复横跳)占 2.3%。
我们的收敛判断是四重保险:
boolean shouldStop(List<Step> history) {
// 1. 硬上限
if (history.size() >= 25) return true;
// 2. 成本上限(不只是步数)
if (totalTokens(history) > 80_000) return true;
// 3. 循环检测:最近 4 步的签名重复
if (hasRepeatingPattern(history, window = 4, minRepeat = 2)) return true;
// 4. 无进展检测:连续 3 步没有获得新信息
if (noNewInformation(history, 3)) return true;
return false;
}
// 第 3 条的实现
boolean hasRepeatingPattern(List<Step> h, int window, int minRepeat) {
if (h.size() < window * minRepeat) return false;
List<String> sigs = h.stream().map(Step::signature).toList(); // 工具名+参数规范化
List<String> tail = sigs.subList(sigs.size() - window * minRepeat, sigs.size());
for (int i = 1; i < minRepeat; i++) {
if (!tail.subList(0, window)
.equals(tail.subList(i * window, (i + 1) * window))) {
return false;
}
}
return true;
}
第 4 条「无进展检测」是我们后来加的,也是最有效的。判断方法是看工具返回的内容和之前某一步是否高度相似(相似度 > 0.92)。它能抓到那些「换了个工具但得到相同信息」的情况——循环检测抓不到这类,因为工具名不一样。
加上这四重之后,>30 步的任务从 7% 降到 0.9%,成本下降 31%。
什么场景我们改回了工作流
这才是这篇最想说的部分。改造上线三个月后,我们统计了各场景的表现:
| 场景 | Agent 成功率 | 工作流成功率 | 平均成本 | 决定 |
|---|---|---|---|---|
| 未知原因故障排查 | 78% | 34% | ¥0.91 | 保留 Agent |
| 容量评估与扩容建议 | 83% | 71% | ¥0.64 | 保留 Agent |
| 发布后健康检查 | 94% | 97% | ¥0.38 | 改回工作流 |
| 告警噪音分类 | 91% | 93% | ¥0.07 | 改回工作流 |
| 日志关键词告警响应 | 89% | 96% | ¥0.22 | 改回工作流 |
改回去的三个场景有个共同特征:处理路径是固定的,不需要探索。
以「日志关键词告警响应」为例,流程永远是:拿到告警 → 拉取上下文日志 → 判断是否需要人工 → 通知。Agent 版本虽然成功率也有 89%,但它偶尔会「自作主张」多查几个指标,单次成本从 0.06 元涨到 0.22 元,而且延迟从 3 秒涨到 19 秒。对一个每天触发 400 次的告警来说,这个代价不值得。
告警噪音分类那个更典型。它的判断逻辑其实很清晰(基于规则 + 一个简单分类),Agent 版本反而在边界 case 上更容易出错——因为模型会「推理」出一个看似合理但不符合我们内部约定的分类。工作流版本直接查分类表,100% 一致。
还有一个隐性成本:Agent 的可解释性差。告警分类错了要复盘,工作流能明确说是哪条规则命中的;Agent 版本只能说「模型这么判断的」,没法改进。
我们的混合架构
最后定下来的架构是这样:
┌─────────────────┐
│ 事件入口 │
│ (告警/定时/人工) │
└────────┬────────┘
▼
┌─────────────────┐
│ 路由器 │ ← 规则 + 小模型分类
│ 决定用哪种模式 │
└────┬───────┬────┘
│ │
已知流程 ───┘ └─── 未知问题
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 工作流引擎 │ │ Agent 循环 │
│ - 固定步骤 │ │ - 感知/规划/行动 │
│ - 确定性执行 │ │ - 工具调用 │
│ - LLM 只做节点处理 │ │ - 记忆系统 │
└──────────────────┘ └──────────────────┘
│ │
└─────────┬─────────┘
▼
┌─────────────────┐
│ 统一的动作执行层 │ ← 权限、沙箱、审计
└─────────────────┘
关键设计有两个。
一是路由器。它决定一个任务走工作流还是 Agent,判断用规则 + 一个很便宜的小模型(qwen-turbo,单次 0.0003 元)。规则覆盖 80% 的情况(告警类型 → 处理方式是有映射表的),剩余的用模型分类。路由错误率 3.2%,可以接受——因为路由错了最多是效果差一点,不会出错。
二是统一的动作执行层。不管是工作流还是 Agent 调用工具,都走同一套权限校验、沙箱执行、审计日志。这一点非常重要,否则 Agent 会成为一个绕过既有安全体系的后门。我们在上一版架构里犯过这个错——Agent 有自己的一套工具调用代码,权限校验是单独实现的,后来发现两边规则不一致。
工具设计:Agent 和工作流的需求不一样
改造过程中我们发现,同一个能力给工作流用和给 Agent 用,接口设计应该不同。
给工作流的工具追求精确——参数明确、返回结构固定、一次调用拿全所有信息:
// 工作流用:一次拿全,参数明确
public PodDiagnosis diagnosePod(String namespace, String podName,
Duration window, boolean includeEvents) {
...
}
给 Agent 的工具追求可探索和容错——参数宽松、返回信息密度高、错误信息要有指导性:
// Agent 用:参数可省略,返回结果自带下一步建议
@Tool(description = """
诊断一个 Pod 的异常状态。如果不确定 Pod 名,先用 list_pods 查询。
返回:状态、事件、最近日志摘要、资源使用率、以及初步判断。
""")
public PodDiagnosis diagnosePod(
@ToolParam(description = "Pod 名,支持前缀匹配", required = true)
String podName,
@ToolParam(description = "命名空间,不传则搜索所有有权限的命名空间", required = false)
String namespace,
@ToolParam(description = "日志时间窗口,单位分钟,默认 30", required = false)
Integer windowMinutes) {
List<Pod> matches = k8s.findPods(podName, namespace);
if (matches.isEmpty()) {
// 错误信息要能指导模型调整行为
return PodDiagnosis.notFound(podName,
"未找到匹配的 Pod。可用 list_pods 查看当前所有 Pod。");
}
if (matches.size() > 1) {
return PodDiagnosis.ambiguous(matches.stream().map(Pod::name).toList(),
"匹配到多个 Pod,请用完整名称。");
}
...
}
「匹配到多个时返回候选列表」这个设计,看起来很小,但对 Agent 的成功率影响很大。以前工具直接抛异常,Agent 只能猜;现在它拿到了候选,下一步就能选对。我们把这种「可恢复的错误」做成了工具设计的规范。
成本和效果的最终数字
混合架构上线两个月,对比纯 Agent 版本:
| 指标 | 纯 Agent | 混合架构 |
|---|---|---|
| 日均任务量 | 1,240 | 1,390 |
| 整体成功率 | 84.2% | 89.7% |
| 日均成本 | ¥682 | ¥341 |
| P50 耗时 | 34s | 11s |
| P99 耗时 | 186s | 92s |
| 因超时放弃的任务 | 4.1% | 1.3% |
成本降了一半,成功率反而提升——因为那些本来就不该用 Agent 的简单任务,用工作流又快又准。
几条判断标准
如果你也在纠结某个场景该用 Agent 还是工作流,我们内部用的检查清单:
- 能不能提前写出完整的步骤?能写出来 → 工作流。写不出来(因为依赖中间结果)→ Agent。
- 出错的代价有多大?代价高(涉及资金、数据删除、对外承诺)→ 工作流,或者 Agent + 强制人工确认。
- 调用频率高吗?每天几百次以上的固定任务 → 工作流,成本差是数量级的。
- 需要可解释吗?事后要复盘归因的 → 工作流。Agent 的决策过程记录得再全,也不如一条明确的规则好解释。
- 输入的变化性大吗?用户自由提问、问题形态千变万化 → Agent。输入是结构化事件(告警、定时任务)→ 大概率工作流。
按我们的经验,一个中等规模的 AI 系统里,大概 60~70% 的场景适合工作流,30~40% 需要 Agent。一开始就全上 Agent,会付出不必要的成本和稳定性代价。
就写到这。如果哪天你也被《AI Agent 架构设计:从 ChatBot 到自主智能体》里同一个坑绊住,回来翻这篇,能省半小时。