④Workflow 还是 Agent?大部分场景你不需要 Agent

同样是"让模型干好几步活",有一种做法是把步骤写死在代码里,模型只负责每一步的内容,这叫 workflow。我们会用 Go 实现五种最常见的 workflow 模式:链式、路由、并行、编排者-执行者、评估-优化。然后你会发现,很多你以为需要 Agent 的场景,一个 workflow 就够了,而且更快、更便宜、更可控。

outlier 13 分钟 4936 字 0 0
系列教程Agent从入门到放弃 第 5 / 5 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
查看教程目录

开场:一个被 Agent 耽误的需求

去年有个同事接了个需求:每天几百条客服工单,要自动分个类,再按类别生成一份回复草稿,交给人工审核。

他的第一反应是:"这不就是 Agent 的活吗?"于是装了一个 Agent 框架,给模型配了十几个工具:查订单、查用户、查退款规则、写草稿、标记分类……然后写了一段很长的 system prompt,让模型"自己看着办"。

两周后的周会上,他的结论是:

  • 慢:一条工单要转五六圈,十几秒才出结果;
  • 贵:每圈都要把十几个工具的定义和越来越长的历史发一遍;
  • 不稳定:同样一条工单,有时候先查订单再分类,有时候先分类再查订单,有时候查着查着就开始写一封跟工单无关的感谢信。

后来我们一起把它重写了。新版本只有几十行代码:第一步,让模型把工单分成"账单 / 技术 / 其他"三类,输出 JSON;第二步,按类别选一段专门的 system prompt,让模型写回复。 没有循环,没有工具,两次模型调用,结果又快又稳。

这个故事的寓意,就是本篇标题:大部分场景,你不需要 Agent。 读完本篇,你应该能回答这几个问题:

  • Workflow 和 Agent 的分界线到底在哪里?
  • 五种最常见的 workflow 模式分别长什么样,各适合什么场景?
  • 什么情况下才真正需要 Agent?
  • 拿到一个需求,怎么判断该用哪一种?

一、分界线:谁决定下一步

1.1 两个定义

第 01 篇画过一条光谱,判断标准是一个问题:下一步做什么,是谁决定的? 这一篇把光谱中间那一段放大看:

flowchart LR subgraph W["Workflow:路径写在代码里"] direction LR W1["步骤 1
模型"] --> W2["步骤 2
模型"] --> W3["步骤 3
模型"] end subgraph A["Agent:路径由模型决定"] direction LR A1(("模型")) -->|"下一步?"| A2["工具 X"] A2 --> A1 A1 -->|"下一步?"| A3["工具 Y"] A3 --> A1 A1 -->|"做完了"| A4["结束"] end style W fill:#e8f5e9,stroke:#2e7d32 style A fill:#fff3e0,stroke:#e65100
  • Workflow:模型和工具按预先写好的路径协作。先做什么、后做什么、做几次,都是程序员写死的;模型只负责每一步里的内容;
  • Agent:模型自己决定路径。用什么工具、调用几次、什么时候算完成,都由它在循环里一步步决定。

这两个定义来自 Anthropic 的工程博客文章《Building Effective Agents》,本篇的五种模式也基本沿用这篇文章的分类。它最常被引用的一句建议是:先找最简单的方案,只在确实需要时才增加复杂度。

1.2 一个简单的检验方法

拿到一段"让模型干好几步活"的代码,找到里面的循环,问一句:这个循环什么时候结束?

  • 如果答案是"跑完三步就结束""最多重试三次",这是 workflow;
  • 如果答案是"直到模型说它做完了",这是 Agent。

配套仓库 examples/ch04-workflows/main.go 里实现了五种 workflow,你可以去数一数:里面没有一个 for 循环是"直到模型说完成"。所有循环的次数和顺序,都写死在代码里。

1.3 两个积木

读这五种模式之前,先认识两个小函数,它们是所有模式的积木。

第一个是 ask:一次最普通的模型调用,system 加一句话进去,文本出来:

// ask 是一次最普通的模型调用:system + 一句话进,文本出。
func ask(system, prompt string) string {
    resp, err := model.Create(ctx, &llm.Request{
        System:    llm.SystemText(system),
        Messages:  []llm.Message{llm.UserText(prompt)},
        MaxTokens: 4096,
    })
    // ……
    return strings.TrimSpace(resp.Text())
}

第二个是 askJSON:要求模型严格按一个 JSON Schema 输出,程序直接解析成 Go 结构体:

// askJSON 用 structured outputs 让模型严格按 schema 输出 JSON。
func askJSON(system, prompt, schema string, out any) {
    resp, err := model.Create(ctx, &llm.Request{
        System:       llm.SystemText(system),
        Messages:     []llm.Message{llm.UserText(prompt)},
        MaxTokens:    4096,
        OutputConfig: llm.JSONSchemaFormat(schema),
    })
    if err == nil {
        err = json.Unmarshal([]byte(resp.Text()), out)
    }
    // ……
}

askJSON 是 workflow 的灵魂。 程序要根据模型的输出决定走哪条路,就必须能可靠地读懂它的输出;自由文本靠不住,JSON 才靠得住。

关于 DeepSeek:它的 JSON 输出模式只保证"输出是一个合法的 JSON 对象",不能用 schema 约束字段,而且要求 prompt 里出现 "json" 这个词。配套仓库的 DeepSeek 客户端会把 schema 写进 system prompt,让模型照着写。所以解析之后最好再检查一下字段是不是齐的,这一点踩坑记录里再说。

二、五种 workflow 模式

2.1 链式(Prompt Chaining):一步的输出是下一步的输入

flowchart LR I["原文"] --> S1["① 提炼 3 条要点"] S1 --> G{"gate:
够 3 条吗?"} G -->|"否"| X["中断"] G -->|"是"| S2["② 翻译成英文"] S2 --> S3["③ 起标题"] style G fill:#fff9c4 style X fill:#ffcdd2,stroke:#b71c1c

最简单的模式:把一个任务拆成固定的几步,每一步是一次模型调用,上一步的输出作为下一步的输入。配套代码里的 chain 是"提炼要点 → 翻译 → 起标题":

outline := ask("你是技术编辑。", "把下面的内容提炼成 3 条要点,每条一行,以「- 」开头:\n"+article)

// gate:程序检查中间结果,不合格就别往下走了。
if strings.Count(outline, "- ") < 3 {
    fmt.Println("要点不足 3 条,链条中断。")
    return
}
en := ask("You are a technical translator.", "Translate into English, keep the bullet format:\n"+outline)
title := ask("你是标题党克星。", "给这几条要点起一个不超过 12 个字的中文标题,只输出标题:\n"+outline)

注意中间那个 gate(检查点)。它是链式模式里最有价值的部分:每一步之后,用程序检查一下结果,不合格就停下或重试,而不是把一个错误的中间结果一路传下去。gate 可以很简单(数一数有几行),也可以是另一次模型调用("这段翻译有没有漏掉内容?")。

适合:任务能清楚地拆成固定的几步,每一步都比整体简单。比如"先写大纲、检查大纲、再写正文","先抽取信息、再生成报告"。

为什么比一次调用好:每一步的 prompt 都很专注,模型更容易做对;中间结果可以检查、可以记录、可以人工介入。代价是延迟变长了,几步就要等几次。

2.2 路由(Routing):先分类,再分流

flowchart LR Q["用户消息"] --> C["分类器
输出 JSON"] C -->|"billing"| B["账单专员的 prompt"] C -->|"technical"| T["技术支持的 prompt"] C -->|"other"| O["通用客服的 prompt"] style C fill:#fff9c4

开场那个客服工单的需求,正确答案就是路由。先让模型分类,再根据分类走不同的处理流程:

var c struct {
    Category string `json:"category"`
    Reason   string `json:"reason"`
}
askJSON("你是客服工单分类器。", "给这条用户消息分类:"+question, `{
    "type":"object",
    "properties":{
        "category":{"type":"string","enum":["billing","technical","other"]},
        "reason":{"type":"string"}
    },
    "required":["category","reason"],
    "additionalProperties":false
}`, &c)

handlers := map[string]string{
    "billing":   "你是账单专员。先表示理解,再给出退款的具体步骤,承诺 3 个工作日内处理。",
    "technical": "你是技术支持。先问清楚设备和版本,再给出排查步骤。",
    "other":     "你是通用客服,礼貌地回答,必要时引导用户补充信息。",
}
fmt.Println(ask(handlers[c.Category], question))

为什么不写一个"什么都懂"的大 prompt?因为把所有情况塞进一个 prompt,每种情况都处理不好:账单问题需要的语气和步骤,和技术问题完全不同,规则一多就互相干扰。路由把"判断是哪种情况"和"处理这种情况"拆开,每一段 prompt 只管一件事。

路由的另一个常见用法是按难度分流到不同的模型:简单问题交给便宜快速的小模型,复杂问题再交给大模型。比如 DeepSeek 就有便宜的 deepseek-flash 和更强也更贵的 deepseek-v4-pro。第 37 篇讲成本时会再说这个"模型路由"。

适合:输入有明显不同的类别,每类需要不同的处理方式,而且分类本身能做得比较准。

2.3 并行(Parallelization):分段,或者投票

并行有两种玩法。

分段(sectioning):把一个任务拆成几个互相独立的子任务,同时跑,最后合并。配套代码让三个"审查员"从安全、正确性、可读性三个角度同时审查同一段代码:

aspects := map[string]string{
    "安全":  "只从安全角度审查这段代码,列出问题,最多 3 条。",
    "正确性": "只从正确性和事务一致性角度审查这段代码,最多 3 条。",
    "可读性": "只从可读性和错误处理角度审查这段代码,最多 3 条。",
}
for name, prompt := range aspects {
    wg.Add(1)
    go func(name, prompt string) {
        defer wg.Done()
        r := ask("你是资深 Go 代码审查者。", prompt+"\n\n"+code)
        // ……存进 results
    }(name, prompt)
}
wg.Wait()

投票(voting):同一个问题问好几次,少数服从多数。配套代码问了三次"这段代码能直接上线吗?只回答 YES 或 NO",最后统计票数。

flowchart TB subgraph 分段 C1["代码"] --> R1["安全审查"] C1 --> R2["正确性审查"] C1 --> R3["可读性审查"] R1 & R2 & R3 --> M1["合并"] end subgraph 投票 C2["同一个问题"] --> V1["第 1 次"] C2 --> V2["第 2 次"] C2 --> V3["第 3 次"] V1 & V2 & V3 --> M2["少数服从多数"] end

分段的好处是专注:每个审查员只盯一个角度,比"请全面审查"挑出的问题更多、更具体。投票的好处是稳:模型的输出有随机性(第 08 篇细讲),单次判断可能看走眼,多问几次能降低偶然误判。

Go 写这种东西特别顺手:几个 goroutine 加一个 sync.WaitGroup,延迟就是最慢那一次调用的时间,而不是几次之和。

适合:子任务互相独立(分段),或者单次判断不够可靠、又值得多花几倍成本去换稳定性(投票)。

2.4 编排者-执行者(Orchestrator-Workers):让模型拆任务

flowchart TB G["目标:写一份 README"] --> O["编排者(模型)
拆成 N 个子任务,输出 JSON"] O --> W1["执行者 1:简介"] O --> W2["执行者 2:安装"] O --> W3["执行者 3:用法"] O --> W4["执行者 N:……"] W1 & W2 & W3 & W4 --> S["主编(模型)
合成最终文档"] style O fill:#fff3e0,stroke:#e65100 style S fill:#fff3e0,stroke:#e65100

这是五种模式里最接近 Agent 的一种。和并行分段的区别是:子任务不是写死的,而是模型根据目标现场拆出来的。 配套代码给编排者一个目标"为一个命令行 TODO 工具写一份 README",让它输出一个子任务列表:

var plan struct {
    Subtasks []struct {
        Title       string `json:"title"`
        Instruction string `json:"instruction"`
    } `json:"subtasks"`
}
askJSON("你是写作项目的编排者,负责把任务拆成可以并行完成的子任务。", goal, `{...}`, &plan)

然后程序把每个子任务并发地派给一个"执行者",最后再调一次模型当"主编",把各节合成一篇。

为什么它还算 workflow 而不是 Agent?因为模型只决定"拆成什么",不决定"下一步做什么"。拆完、派发、汇总,这个三段式的框架是程序写死的。模型不能说"我先写两节看看,再决定要不要加一节"。

适合:任务能拆成若干个独立的部分,但具体拆成哪几部分要看输入。比如"按这份需求文档改动涉及的几个模块","把这份长报告按主题分头总结"。

2.5 评估-优化(Evaluator-Optimizer):一个写,一个评

flowchart LR T["任务"] --> W["写手(模型)
写初稿"] W --> E{"评审(模型)
pass?"} E -->|"不通过 + 反馈"| W E -->|"通过"| D["完成"] E -.->|"三轮还没过"| X["放弃"] style E fill:#fff9c4 style X fill:#ffcdd2,stroke:#b71c1c

最后一种模式像代码审查:一个模型写,另一个模型(或同一个模型换个 system prompt)评,不通过就带着反馈重写。配套代码让它写一个能正确处理中文的回文判断函数:

draft := ask("你是 Go 工程师。", task)
for round := 1; round <= 3; round++ {
    var review struct {
        Pass     bool   `json:"pass"`
        Feedback string `json:"feedback"`
    }
    askJSON("你是严格的代码评审。只有完全满足需求、能正确处理 Unicode 时才给 pass=true。",
        "需求:"+task+"\n\n代码:\n"+draft, `{...}`, &review)
    if review.Pass {
        return
    }
    draft = ask("你是 Go 工程师。", task+"\n\n上一版:\n"+draft+"\n\n评审意见:\n"+review.Feedback+"\n\n请修改后重新输出完整代码。")
}
fmt.Println("三轮还没过,放弃。(这就是评估-优化也要设上限的原因)")

这里有一个 for 循环,但它最多三轮,不是"直到模型满意"。这个上限非常重要:评审有时候会一直挑出新毛病,写手有时候会在两种写法之间来回改,没有上限就是一个烧钱的死循环。

适合:有明确的评价标准,而且"带着反馈改一版"确实能让结果变好。比如翻译(评审检查有没有漏译、术语对不对)、代码(评审检查边界情况),以及一切"人类审一遍、提意见、再改一版"能提升质量的事情。

三、那 Agent 什么时候才需要?

3.1 当你没法提前写出路径

回到那个检验方法:Agent 的循环是"直到模型说做完了"。什么时候你只能这么写?当你根本没法提前知道需要几步、每步做什么的时候。

比如"修复这个项目里失败的测试":

  • 你不知道测试为什么失败,可能是一行拼写错误,也可能是三个模块的接口不匹配;
  • 你不知道要读几个文件,读完一个才知道下一个该读哪个;
  • 你不知道改完之后会不会引出新的失败。

这种任务,路径只能在执行过程中根据结果一步步决定。workflow 写不出来,只能交给模型自己转。开放式、步骤数不可预测、需要根据中间结果调整方向,这是 Agent 的地盘。

3.2 一张对比表

单次调用WorkflowAgent
路径由谁决定没有路径程序员模型
可预测性高高低
延迟一次调用几次调用,可以并行几次到几百次,很难预估
成本低可预估难预估
调试看一次输入输出看每一步的中间结果看几十轮的消息历史
出错方式答错某一步答错,gate 能拦住跑偏、绕圈、目标漂移
适合问答、改写、翻译步骤固定的多步任务开放式、步骤不可预测的任务

Agent 拿可预测性、成本和延迟,换来了灵活性。这笔交易只有在任务确实需要灵活性的时候才划算。

3.3 怎么选:一棵决策树

flowchart TD Q["一个需求"] --> A{"一次调用
(加上好的 prompt 和检索)
能搞定吗?"} A -->|"能"| R1["单次调用"] A -->|"不能"| B{"能提前写出
固定的步骤吗?"} B -->|"能"| R2["Workflow
选一种模式"] B -->|"不能"| C{"结果能被验证吗?
(测试、编译器、规则)"} C -->|"能"| R3["Agent"] C -->|"不能"| R4["Agent + 人在回路
或者重新想想需求"] style R1 fill:#e3f2fd,stroke:#1565c0 style R2 fill:#e8f5e9,stroke:#2e7d32 style R3 fill:#fff3e0,stroke:#e65100 style R4 fill:#ffcdd2,stroke:#b71c1c

几个经验:

  • 从最简单的开始:先试单次调用。很多"需要 Agent"的需求,其实是 prompt 没写好,或者上下文里缺了关键信息;
  • workflow 不丢人:把步骤写死,意味着你对这个任务理解得足够深。可预测、好调试、好优化,这是工程上的优点;
  • 可以组合:workflow 的某一步可以是一个 Agent("这一步让 Agent 去修测试"),Agent 的某个工具也可以是一个 workflow。第 34 篇用 git worktree 并行跑多个 Agent,外层的编排就是一个 workflow;
  • 结果不可验证的任务,上 Agent 要格外小心:没有测试或规则把它拉回来,跑偏了也不会有人知道。这是 AutoGPT 当年最大的坑(第 03 篇)。

一条原则:能用 workflow 解决的,就别上 Agent。

四、动手:五种模式跑一遍

实验 1:五个子命令

go run ./examples/ch04-workflows chain
go run ./examples/ch04-workflows route "我上个月被扣了两次会员费"
go run ./examples/ch04-workflows parallel
go run ./examples/ch04-workflows orchestrate
go run ./examples/ch04-workflows optimize

这五个都能在 Playground 的第 04 篇里点着跑。

不设 Key 时用的是离线演示模型,它不理解内容,但能让你看清流程:

  • chain:演示模型会照着"以「- 」开头列 3 条"的要求输出三行,gate 检查通过,链条走完三步;
  • route:分类结果是演示模型从三个枚举值里随机挑的,多跑几次,你会看到不同的类别把请求分到了不同的 system prompt;
  • parallel:三个审查同时跑完,投票结果是随机的 YES / NO;
  • orchestrate:演示模型按 schema 拆出两三个子任务,程序并发派发再汇总;
  • optimize:评审的 pass 是随机的,有时一轮就过,有时三轮都过不了,你能看到那句"三轮还没过,放弃"。
想看这些模式在真实模型上的效果,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。

接上 DeepSeek 之后,重点看几处:

  • route 换几种说法试试,比如"App 一打开就闪退""你们公司在哪里",看分类准不准;
  • parallel 里三个审查员挑出的问题有没有重叠,它们加起来是不是比一次"全面审查"更细;
  • optimize 里评审给的反馈是不是具体,第二版有没有真的按反馈改。

实验 2:拆掉 gate 试试

把 chain 里那段 gate 检查删掉,再把第一步的 prompt 改成"提炼成要点"(不说几条、不说格式)。跑几次真实模型,看看后面的翻译和起标题会受什么影响。你会体会到:没有检查点的链条,错误会一路传下去,而且越往后越难发现。

实验 3:把一个 workflow 改成 Agent

拿 route 做个思想实验:如果把它改成 Agent,你会给模型哪些工具?system prompt 怎么写?它每处理一条工单要转几圈?和现在的两次调用比,延迟和成本差多少?

不用真的写出来,在纸上推演一下就够了。推演完,你大概会同意开场那个故事的结论。

五、动手作业

拿自己工作或生活里的三个需求,比如"每天汇总一下群聊里的待办""帮我审一下这个 PR""把这份 Excel 里的地址标准化",对每一个回答:

  1. 一次调用能不能搞定?
  2. 如果不能,能不能提前写出固定的步骤?适合五种模式里的哪一种?
  3. 如果写不出固定步骤,结果能不能被验证?
  4. 你最终的选择是什么?为什么?

如果三个需求里有两个最后选了 workflow,恭喜你,你已经比两年前的我清醒了。

六、踩坑记录:workflow 里的六个坑

坑 1:一上来就用 Agent 框架。
开场那个故事就是。Agent 框架很强大,但强大意味着复杂:你要调 system prompt、调工具描述、调循环次数,还要处理它的各种"自由发挥"。先用最简单的方式实现,跑通了、知道瓶颈在哪里了,再考虑要不要升级。

坑 2:链式模式不设 gate。
第一步就错了,后面每一步都在错误的基础上认真工作,最后得到一个非常完整、非常流畅、完全错误的结果。gate 是 workflow 相对于 Agent 的最大优势,不用就亏了。

坑 3:结构化输出直接信任。
askJSON 解析成功,不代表内容是对的:枚举值可能拼错,必填字段可能是空字符串。换到 DeepSeek 这种只保证"是合法 JSON"、不保证"符合 schema"的模式,还可能少字段。它的文档也提醒过,偶尔会返回空内容。所以解析之后再校验一遍:分类值在不在枚举里,列表是不是空的,不合格就重试或走兜底分支。

坑 4:投票时三票一模一样。
同一个 prompt、同一个模型,如果采样的随机性被关掉了(比如 temperature 设成 0),三次投票的结果会完全一样,等于只问了一次,还花了三倍的钱。投票要么保留随机性,要么换着用不同的 prompt 或模型。第 08 篇会讲 temperature 到底控制了什么。

坑 5:评估-优化不设上限,或者评审太好说话。
不设上限是死循环;评审太好说话,第一版就给 pass,等于没评。更隐蔽的一种是评审只看写手自己的话:"我已经全面考虑了 Unicode……",评审信了。所以评审的 prompt 要逐条列出标准,最好让它先写理由、再给结论。第 36 篇讲 LLM-as-judge 时会细说怎么校准评审。

坑 6:编排者拆出的子任务互相打架。
编排者拆的几个子任务可能有重叠("简介"和"功能介绍"写了同样的内容),也可能有遗漏。所以最后那一步"主编合成"很重要,它的 prompt 里明确写了"去掉重复,统一术语"。如果子任务之间强依赖,后一个要用前一个的结果,那就别并行了,改成链式。

一句话总结

Workflow 是程序员把路径写死、模型负责每一步的内容;Agent 是模型自己决定路径;五种 workflow 模式能覆盖大部分"让模型干好几步活"的需求,只有当步骤真的没法提前写出来、而且结果可以验证时,才轮到 Agent。

flowchart LR S["单次调用"] -->|"不够用"| W["Workflow
链式 / 路由 / 并行
编排者-执行者 / 评估-优化"] W -->|"路径写不出来
且结果可验证"| A["Agent"] style S fill:#e3f2fd,stroke:#1565c0 style W fill:#e8f5e9,stroke:#2e7d32 style A fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭