降SpringAI阿里第10掌-双龙取水-百阵流程

降SpringAI阿里第10掌-双龙取水-百阵流程

流程是确定的,就别再交给采样来决定

[读者] 已经用 ReactAgent 跑通数字人,但回答开始飘的 Java 后端
[痛点] 同样输入两次结果不同,该查的没查,该问的没问
[现在读] 你要把流程控制权从模型手里收回到 Java 编排层
[读完] 能用顺序/并行/路由/循环四类 Flow Agent 重构数字人链路
[旧方案] 一个 ReactAgent + 全量工具 + 超长 System Prompt
    |
    v
[新需求] 售前售后分流、知识与业务并行查询、缺信息主动追问
    |
    v
[冲突] 步骤走不走、走几遍,全由模型逐 token 采样决定
    |
    v
[后果] 不可复现、延迟与 token 不可控、关键节点无法埋点与校验

我是老李,一线 Java 后端,这些年主要在深圳做企业级 AI 应用。这个系列我陪着一个真实项目往前走:一个能注册登录、能配标题主题色、能挂在 Web 运行页上也接 LiveKit 实时语音的数字人。框架版本固定 alibaba/spring-ai-alibaba v1.1.2.2,JDK 21 LTS,练习仓库在 https://github.com/lifuchun522/springaialibabapractice 。

到第 9 掌为止,数字人的主链路已经跑起来了:用户一句话进来,后台一个 ReactAgent 带着 RAG 检索工具、订单查询工具、知识库工具,自己决定先干什么、后干什么。第一次看它自己先查订单再解释政策的时候,我是真的拍了下桌子——这东西看起来有脑子。

但演示和试运行之间隔着一整个世界的距离。产品同学第二天就提了三条要求:售前咨询走咨询话术、售后问题走工单流程;知识库检索和业务系统查询要同时做,不能串行等;用户没说清是哪台设备时必须追问,不许猜。

这三条,我该写进 System Prompt,还是写成 Java 代码?

如果继续塞 Prompt,等于把流程的决定权留在概率系统里;如果改成代码编排,那我这套已经跑起来的 ReactAgent 架构要动手术。

选错了会怎样?不是回答得不够聪明,而是售后问题被答成了售前承诺——用户拿着这句话去找客服,那就是事故。

01、故事

试运行第一天,数字人遇到一个提问:我这台 X1 送修大概要多久?

标准链路应该是:先判断属于售后,再查工单系统里这台设备的状态,然后检索售后政策里的返修时效,最后组织回答。

我把这段话原样写进了 System Prompt。第 10 次调用,它开始不一样了。十次请求里,三次没调知识库,直接凭常识答了句大概三个工作日;两次走了售前话术,说我们的服务响应很快;还有一次反问用户买的是哪款产品,而产品型号就在用户第一句话里。

任务是:把这四条链路做成可复现的流程,而不是每次重新赌一次采样结果。

当前方案只有一个 ReactAgent,所有决策都在它的循环里。变化来了——从一个人问一句,变成一个流程要同时覆盖四类场景。冲突在于,ReactAgent 的每一次工具选择都是一次采样,采样就有方差,方差在演示里看不出来,在试运行里就是事故。

演示看惊艳
试运见漂移
十次三不同
方知权在谁

02、问题

旧方案为什么顶不住?因为 System Prompt 是概率约束,不是结构约束。

业务影响有三层:用户拿到错误的时效承诺;客服侧出现无法追溯的对话;运营想统计有多少问题根本没走检索时,数据根本拿不到。

技术表现同样清晰:同输入多次调用路径不一致;单次耗时方差大;token 消耗方差大;没有地方插入检索前的权限校验和脱敏;没有地方对分流做 A/B 对照。

可验证的完成标准,我定成四条:

  1. 同一问题连续调用二十次,顺序模式下的节点序列完全一致;
  2. 并行模式两条分支都有独立产出,且互不覆盖;
  3. 售前、售后各十组样本,路由命中全部正确;
  4. 每次响应都能打印出经过的节点名与各自耗时。

这四条不是性能指标,是结构指标。结构指标没过,谈优化就是空谈。

提示是建议
代码是契约
路径可复现
才叫做交付

03、原理

Flow Agent 的本质,是把控制流的决策权从模型侧移到 Java 侧。v1.1.2.2 的 agent framework 内置了四类:

  • SequentialAgent:子 Agent 按声明顺序执行,前一个的产出作为后一个的输入。
  • ParallelAgent:子 Agent 并发执行,结果汇聚。
  • RoutingAgent / LlmRoutingAgent:由一次 LLM 分类调用决定走哪个子 Agent,而不是让模型在循环里自己挑。
  • LoopAgent:子 Agent 反复执行,直到满足退出条件或达到最大迭代次数。

反直觉判断:Flow Agent 不是能力更弱的 ReactAgent,恰恰相反。它把 LLM 从流程规划者降级成节点执行者,整个系统的可用性反而上升了。因为 LLM 最擅长的是语义判断和自然语言生成,最不擅长的恰恰是保证第三步一定在第二步之后发生。

第二个反直觉判断:并行不一定更快。ParallelAgent 的两条分支同时在跑,token 消耗是叠加的,服务端还有并发限流。如果两条分支里只有一条的结果最终会被用上,并行就是纯粹的浪费。并行解决的是两条结果都需要的延迟问题,不是猜哪条对的问题。

第三点,是路由能用 LLM 还稳的根本原因:分类比规划简单得多。让模型输出一个标签,远比让它输出一串动作序列稳定。LlmRoutingAgent 把 LLM 调用压缩成一次分类,这才是确定性流程里唯一保留 LLM 的地方——因为它本来就该由语义判断决定。

模型善辨义
不善守流程
编排归代码
判断归模型

04、架构

[输入] 用户一句话(Web 运行页输入 / LiveKit 语音转写)
    |
    v
[模块] LlmRoutingAgent 售前售后路由 + 四类 Flow Agent 编排层
    |
    v
[数据/状态] 会话状态:sessionId、命中分支、检索片段、业务记录、缺失字段
    |
    v
[处理] Sequential 主链 / Parallel 并行支链 / Loop 补信息环
    |
    v
[输出] 数字人回复 + 节点级埋点(分支、节点序列、耗时)

边界要说清楚:编排层只负责谁在什么条件下跑,不负责内容怎么生成。任何需要语义判断的地方——意图分类、答案组织、追问措辞——仍然是 LLM。

收益:链路可复现、节点可埋点、可以插入校验(权限、脱敏、限流),单点失败可以降级。

代价有两个。一是流程之外的问题必须显式兜底,否则会被硬塞进某个分支;二是子 Agent 之间的输出契约要明确定义,否则上一个节点的自由文本会污染下一个节点的输入。

适用条件也很直白:流程边界能被清楚画出来的场景。画不出来,就别急着编排。

路由定分支
顺序定链路
并行定吞吐
循环定完整

05、实战一次

环境与版本:JDK 21 LTS,Spring Boot 3.x,alibaba/spring-ai-alibaba v1.1.2.2,模型走 DashScope 的 qwen 系列。

说明放在前面:以下代码按 v1.1.2.2 的 agent-framework 模块组织,具体包路径与 builder 方法名请对照官方 examples 与源码核验;未在当前环境实测,以下为预期结果。

依赖(坐标与版本以官方 Releases 的 BOM 为准核验):

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-bom</artifactId>
            <version>1.1.2.2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-agent-framework</artifactId>
</dependency>

配置:

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.2

路由节点与抽取节点建议低 temperature,生成节点可以单独放开,这一点在后面的实战里会体现。

核心实现第一部分,顺序链的三个原子节点:

@Configuration
public class Ch10FlowConfig {

    @Bean
    public ReactAgent understandAgent(ChatModel chatModel) {
        return ReactAgent.builder()
                .name("understand")
                .model(chatModel)
                .instruction("""
                        你是数字人的理解节点,只做三件事:
                        1) 用一句话复述用户诉求;
                        2) 抽取实体:产品名、问题类型、时间范围、订单号;
                        3) 列出当前缺失的信息。
                        禁止直接回答用户,只输出结构化结果。
                        """)
                .build();
    }

    @Bean
    public ReactAgent retrieveAgent(ChatModel chatModel, VectorStore vectorStore) {
        return ReactAgent.builder()
                .name("retrieve")
                .model(chatModel)
                .instruction("""
                        你是检索节点。基于上游抽取出的实体调用检索工具,
                        只返回检索到的原文片段与来源标识,不要总结,不要作答。
                        """)
                .tools(new KnowledgeBaseTool(vectorStore))
                .build();
    }

    @Bean
    public ReactAgent answerAgent(ChatModel chatModel) {
        return ReactAgent.builder()
                .name("answer")
                .model(chatModel)
                .instruction("""
                        你是回答节点。只能基于上游给出的检索片段与业务记录组织回答,
                        片段里没有的内容不要补充。回答完请附上引用的来源标识。
                        """)
                .build();
    }
}

检索工具本身是一个普通的 Spring Bean,用注解方式暴露给模型:

public class KnowledgeBaseTool {

    private final VectorStore vectorStore;

    public KnowledgeBaseTool(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    @Tool(description = "按语义检索数字人产品知识库,返回最相关的原文片段")
    public String search(String query) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder().query(query).topK(4).build());
        return docs.stream()
                .map(Document::getText)
                .collect(Collectors.joining("\n---\n"));
    }
}

核心实现第二部分,把三个节点串成顺序链:

    @Bean
    public SequentialAgent qaFlow(ReactAgent understandAgent,
                                  ReactAgent retrieveAgent,
                                  ReactAgent answerAgent) {
        return SequentialAgent.builder()
                .name("qa-sequential")
                .description("顺序流程:理解 → 检索 → 回答")
                .subAgents(List.of(understandAgent, retrieveAgent, answerAgent))
                .build();
    }

核心实现第三部分,并行召回。关键点不在并行本身,而在每条分支写自己的状态键:

    @Bean
    public ParallelAgent parallelRecall(ChatModel chatModel,
                                        VectorStore vectorStore,
                                        BizQueryService bizQueryService) {
        ReactAgent knowledgeQuery = ReactAgent.builder()
                .name("knowledge-query")
                .model(chatModel)
                .instruction("你是知识库查询节点,只返回原文片段与来源,不要总结。")
                .tools(new KnowledgeBaseTool(vectorStore))
                .outputKey("knowledge_hits")
                .build();

        ReactAgent bizQuery = ReactAgent.builder()
                .name("biz-query")
                .model(chatModel)
                .instruction("你是业务查询节点,只返回订单与工单的结构化字段,不要解释。")
                .tools(new BizQueryTool(bizQueryService))
                .outputKey("biz_records")
                .build();

        return ParallelAgent.builder()
                .name("parallel-recall")
                .description("并行召回:知识库 + 业务系统")
                .subAgents(List.of(knowledgeQuery, bizQuery))
                .build();
    }

核心实现第四部分,售前售后路由。这里 LLM 只做一次分类:

    @Bean
    public LlmRoutingAgent presaleAfterSaleRouter(ChatModel chatModel,
                                                  ReactAgent presaleAgent,
                                                  ReactAgent aftersaleAgent) {
        return LlmRoutingAgent.builder()
                .name("presale-aftersale-router")
                .model(chatModel)
                .description("""
                        售前:产品能力、价格、试用、选型、私有化部署咨询。
                        售后:已购买产品的安装、故障、退款、工单、进度查询。
                        只看用户当前这一句话做判断,不要推理多轮。
                        """)
                .subAgents(List.of(presaleAgent, aftersaleAgent))
                .build();
    }

核心实现第五部分,补信息循环。缺信息不能猜,但也不能无限追问:

    @Bean
    public LoopAgent infoCompletionLoop(ReactAgent checkCompletenessAgent,
                                        ReactAgent askBackAgent) {
        return LoopAgent.builder()
                .name("info-completion-loop")
                .description("信息补齐:校验完整度,缺失则追问,最多两轮")
                .subAgents(List.of(checkCompletenessAgent, askBackAgent))
                .maxIterations(2)
                .build();
    }

对外入口保持普通 Controller,数字人前端不用感知内部编排:

@RestController
@RequestMapping("/api/digital-human")
public class DigitalHumanWorkflowController {

    private final LlmRoutingAgent routingAgent;

    public DigitalHumanWorkflowController(LlmRoutingAgent routingAgent) {
        this.routingAgent = routingAgent;
    }

    @PostMapping("/ask")
    public Map<String, Object> ask(@RequestBody AskRequest request) {
        String answer = routingAgent.invoke(request.question());
        return Map.of("answer", answer);
    }

    public record AskRequest(String question) {
    }
}

启动与请求:

export DASHSCOPE_API_KEY=sk-你的key
mvn -q spring-boot:run
curl -s -X POST http://localhost:8080/api/digital-human/ask -H 'Content-Type: application/json' -d '{"question":"你们的数字人支持私有化部署吗?"}'

示例输出(格式示意,非实测数据):

{
  "branch": "presale",
  "nodes": ["router", "presale"],
  "answer": "支持。我们提供私有化部署方案,具体取决于知识库规模与并发要求……"
}

验收按三条走:顺序链看日志里的节点顺序是否恒为 understand → retrieve → answer;并行链看两条分支是否各自打印耗时、最终合并结果里两个来源是否都在;路由链构造售前与售后样本,看是否命中预期分支。

先束其筋骨
再任其才思
三条链跑通
方知何处柔

06、排查

第一组现象:同一句 X1 送修要多久,十次里有三次回答中没有任何知识库片段。

怀疑方向是检索工具坏了,或者向量库索引为空。

检查动作:打开知识库工具自身的调用日志,再单独直接调用 search 方法,返回完全正常;然后回头看那三次请求的完整 trace。

证据:trace 里这三次请求根本没有出现 retrieve 节点的任何工具调用记录,模型从 understand 直接跳到了 answer。

根因:检索是否发生,是模型在每一轮 decide 出来的,不是流程保证的。Prompt 写得再重,也只是把去检索的采样概率抬高了一点。

修复:用 SequentialAgent 把 understand → retrieve → answer 固定成顺序链,让跳过这一步在结构上不存在。

第二组现象:并行改造上线后,回复里只剩业务查询结果,知识库内容不见了。

怀疑方向是 ParallelAgent 没生效,只有一条分支真的跑了。

检查动作:打开两条分支各自的耗时日志,再打印最终状态对象里的 key 集合。

证据:两条分支的耗时都有记录,说明都在跑;但状态里只有一个检索相关的 key,且被写了两次。

根因:两条分支写了同一个状态键,后写覆盖先写。这不是并发问题,是命名冲突。

修复:每条分支写自己的 key(knowledge_hits 与 biz_records),再加一个汇总节点读取两者做合并。

错误尝试: 我一开始的修法是把 System Prompt 扩到一千五百字,逐条罗列必须先检索再回答、售后必须走工单、信息不足必须追问。为什么错:这是概率约束而不是结构约束。长上下文里指令会被稀释,模型不遵守时你没有任何可观测点——没有节点,就没有埋点,也就没有证据。更糟的是,每一轮都要为这一千五百字付 token 和延迟,成本花在了说服模型守规矩上,而不是解决问题上。

怪模型不听话
实为键名相撞
证据看 trace
根因看状态

07、优化

把根因归纳成一句:流程属于控制流,控制流不能由采样决定。

V2 做四处修改。

第一处,顺序链。修改:understand → retrieve → answer 用 SequentialAgent 固定。原因:检索必须发生。新行为:trace 中节点序列恒定。验证:同一输入跑二十次,对比节点序列是否完全一致。

第二处,并行链。修改:两条分支写独立 key,并新增 merge 节点显式归并。原因:消除状态键冲突。新行为:合并结果同时包含知识库片段与业务记录。验证:断言最终状态里两个 key 都存在且非空。

第三处,路由。修改:加 LlmRoutingAgent,只做一次分类。原因:把该走哪条流程,从多步推理压成单次分类。新行为:分支决策可复现、可统计。验证:构造售前售后样本各十组,比对命中分支。

第四处,补信息。修改:加 LoopAgent,最多两轮。原因:缺信息时不能猜,但也不能无限追问。新行为:最多追问两轮,超限走兜底话术转人工。验证:构造信息严重缺失的样本,观察轮次与终止条件是否如预期。

这里没有实测数据,所以我不下任何性能结论,只留验证方法。想拿结论,先跑样本。

收权于编排
还语义于模型
埋在节点里
判在证据上

08、演进

[同一输入:我这台 X1 送修要多久?]
    |
    +--[V1 单体 ReactAgent]
    |     行为:模型自己决定是否检索、是否追问
    |     代价:路径不可复现;失败点不可观测;每轮为 Prompt 付 token
    |
    +--[V2 Flow Agent 编排]
    |     行为:路由一次分类 → 顺序链固定节点 → 必要处并行 → 缺信息循环
    |     代价:流程外问题需显式兜底;节点间数据结构要约定
    |
[Trade-off]
得到:可复现、可埋点、可校验、成本可估算
失去:对流程外问题的顺手一答
适用:流程边界清晰、对稳定性有要求的业务链路

逐项比较。正确性上,V1 靠模型自觉,V2 靠结构保证,但 V2 的正确性上限取决于每个节点内部 LLM 的输出质量。稳定性上,V2 明显高于 V1。复杂度上,V2 更高,多了编排配置和状态契约两笔账。成本上,V2 的 token 花在节点上而不是 Prompt 上,通常更可控,但并行分支会叠加消耗。适用范围上,V2 适合流程清晰的链路,开放式探索型任务仍应保留 ReactAgent。

遗留问题也列清楚:流程外的长尾问题怎么兜;跨轮会话状态怎么清理;多分支结果冲突怎么归并。这三条会一直带到后面的章节里。

自主换灵活
确定换复现
二者非对立
分层即兼得

09、洞见

9.1 编排不是降级,是放大器

反直觉判断:Flow Agent 常被理解成能力更弱的 Agent。实际上,把 LLM 从流程规划者降级成节点执行者之后,系统的整体可用性是上升的。因为流程规划本质上是状态机问题,状态机不该用采样实现。你拿走的是它做不好的那部分,留下的是它做得最好的那部分。

9.2 分类比规划稳得多

反直觉判断:路由之所以敢用 LLM 还稳,是因为它只要求模型做一次分类。让模型输出一个标签,比让它输出一串动作序列,方差小一个量级。这也是 LlmRoutingAgent 能在确定性流程里保留 LLM 的原因——它保留的是判断,不是流程。

9.3 没有节点就没有埋点

可观测性不是加日志加出来的,是架构给的。V1 的失败点藏在一段自由文本的推理里,你想查也无处下手;V2 的每个节点都是可以命名、可以计时、可以断言的单元。想要线上可诊断,先要有可命名的边界。

9.4 并行的代价是叠加

并行缩短的是关键路径延迟,代价是总量翻倍。只有在两条结果都需要时,并行才是赚的。判断标准很简单:如果有一条分支的结果可以被直接丢掉,那它就不该并行。

9.5 边界之外必须留出口

确定性流程最大的风险,是遇到不属于任何分支的问题时硬塞。每个编排入口都要有兜底分支,而兜底分支完全可以是一个 ReactAgent——这就是两种范式的接缝。

稳在分类易
漂在规划难
骨架宜刚性
肌肉宜柔韧

10、系统落地

原来有什么:第 1 到第 9 掌搭起来的数字人底座,注册登录、项目 CRUD、标题与主题色与背景图、开场白与结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互,再加上第 9 掌的单体 ReactAgent 主链路。

本篇新增什么:agent framework 的四类 Flow Agent 编排层。数字人对话从「一个 Agent 自己看着办」变成「路由 + 顺序 + 并行 + 循环」的显式流程。分支落在 chapter/10-workflow-agents,建议提交信息是 feat(ch10): add workflow agent patterns。

现在能做什么:售前售后能自动分流;知识和业务数据能同时查;关键信息缺失时会追问;每次回答都能列出经过的节点和各自耗时。

还缺什么:这套流程依然是单进程内的单体编排,会话状态还在内存里,没有跨服务的能力拆解,也没有评测集来判断改了编排之后到底变好还是变坏。

下一步如何演进:把编排从四类固定模板下沉到状态图,拿到更细的控制粒度、可持久化的状态与中断恢复能力;再往后才是多 Agent 与分布式协同。

底座尚能跑
流程今有骨
未及多体时
先补评测眼

11、小结

Q1 → Flow Agent 解决的是控制流不该由采样决定的问题:步骤需要结构保证,而不是概率保证
Q2 → 四类边界:顺序管链路、并行管吞吐、路由管分流、循环管完整性,都只管谁跑,不管内容怎么生成
Q3 → 选择依据:流程能画出来就用确定性编排,语义判断、开放探索、长尾兜底留给 ReactAgent
状态 → 数字人主链路具备可复现、可埋点、可校验的编排能力,顺序/并行/路由三种模式已跑通

再强调一次那个接缝:编排层负责形状,Agent 负责内容。两者都在,系统才完整。

三问皆有答
四型皆可跑
边界心中明
落地有章法

12、作业

12.1 理解题:为什么把「必须先检索再回答」写进 System Prompt,不等于一定会先检索?

参考答案:System Prompt 是上下文,不是控制流。模型是否发起工具调用,是每一轮基于当前上下文采样出来的决策,本身存在方差。要让它一定发生,必须由编排层保证:放进 SequentialAgent 的固定节点序列里,让跳过这个选项在结构上不存在。另外,即使模型某次遵守了,你也没有节点级的观测点来证明它遵守了——能证明,才是工程。

12.2 实战题:给数字人加一个退款咨询分支,这个分支放在路由层还是节点层?

参考答案:放在路由层。退款属于意图分流,是一次分类问题,适合由 LlmRoutingAgent 在 description 里声明边界,和售前、售后区分开。判断依据是:这类决策只需要看这句话属于哪一类,不需要多步推理,也不需要工具调用,压缩成一次分类既省 token 也更稳定。如果放到节点层,你得先有一个能稳定抽取是否退款的结构化输出源,反而绕远。

12.3 排障题:并行分支的结果出现丢失,如何定位?

参考答案:分三步。第一,确认两条分支是否都执行了,看各自的耗时日志或 trace 节点记录。第二,打印最终状态对象的 key 集合,确认是否存在两个分支写同一个 key 的情况。第三,如果是 key 冲突,改为每个分支写独立 key,并新增合并节点显式归并。关键是区分并发没生效和结果被覆盖这两类完全不同的根因:前者查调度,后者查命名。

12.4 架构判断题:是否应该把所有链路都改成 Flow Agent?

参考答案:不应该。Flow Agent 的前提是流程边界能被清楚画出来。对于开放式探索型任务,比如帮用户设计一套方案、做多轮头脑风暴,把步骤写死会直接切断模型的价值。合理做法是分层:外层用确定性编排保证该做的步骤一定做,内层在需要探索的节点里放 ReactAgent。判断口诀是——步骤能不能提前画完,能画完就编排,画不完就留给 Agent。

13、思考

本篇的核心冲突其实是一句话:控制流的决定权该放在哪。

模型是概率系统,你让它多数时候对是容易的,让它每一次都对是做不到的。而工程对链路的要求恰恰是后者。Flow Agent 的价值不在于它更聪明,而在于它把是否执行某一步,从一个采样问题变回了一个代码问题。

可复用的判断:凡是能用 if/else 和顺序表达清楚的东西,就不要用自然语言去请求模型遵守;凡是需要语义理解和开放生成的环节,就不要用代码去硬编码规则,把它交给 Agent,并且给它一个明确的输入输出契约。

确定性流程和自主推理不是两套方案,而是同一套系统里的骨骼和肌肉。骨骼负责形状和稳定,肌肉负责适应和发力。难的从来不是选边站,而是把两者的接缝画在哪里——这个接缝,就是你的节点契约。

能确定的别猜
该问清的就问
边界画在纸上
稳定长在架构

posted @ 2026-10-10 23:38  李福春  阅读(2)  评论(0)    收藏  举报