之前我用 Java + Spring AI 做过 RAG 知识库和销售数据 Agent。它们主要是文本交互:用户提问,Agent 选择工具,整理结果并返回答案。做完之后,我想再尝试一个更接近连续业务对话的场景——用户不需要一次说清楚需求,可以边说边修改条件,系统还得记得前面发生了什么。

于是有了 Voice Shopping:用户对着麦克风说“我想买双跑鞋,预算 500,膝盖不太好”,系统识别需求、追问缺少的信息、筛选商品,再用语音解释推荐理由。难点在于,用户说话不会按表单来:有时漏掉条件,有时临时改主意。LLM 要听懂这些变化,程序也得知道接下来该追问、推荐,还是确认订单。

一、为什么从单 Agent 开始拆分

一开始,我给单 Agent 挂上意图识别、澄清、商品搜索、重排、推荐理由、情感润色等工具,让它自己规划调用顺序。但对话稍微复杂一点,调用顺序和上下文就容易出问题。

例如用户只说“买双跑鞋”,模型有时会跳过追问直接检索。把分类规则和推荐要求塞进同一份 Prompt 后,生成的推荐内容里还混入过意图识别用的 JSON 字段。换成语音交互就更麻烦了:如果需求还没问清楚就播报推荐,后面再纠正也很别扭。所以,什么时候开始播报,也得由流程控制。

拆分也有成本:每增加一个 Agent,就要多维护一份上下文和调用关系。预算比较、价格过滤、订单状态转移这些确定性的工作,交给普通业务代码更直接。

最后,我把它拆成了四个 Worker Agent,再用一个 Java 编写的 Orchestrator 控制调用顺序。

二、四个 Worker 的分工

先看几句导购对话,就能看出每一步要处理的问题不太一样:

用户表达真正需要处理的事
“我想买双跑鞋,预算 500,膝盖不太好。”识别购买意图,提取品类、预算和用途偏好
“平时在水泥路上跑。”合并新的使用场景,判断条件是否足够
“再便宜点的有吗?”理解对上一轮推荐的调整,而不是重新开始聊天
“刚才那双 Nike,想换软一点的。”找到上下文商品,保留有用条件,更新偏好
“就第二款吧。”解析推荐列表中的商品引用,进入订单处理

对应到实现中,四个 Worker 是这样分工的。

IntentAgent 先判断用户想做什么,用轻量模型 qwen-turbo 做意图分类和槽位提取,输出结构化结果。系统定义了六类意图:PRODUCT_RECOMMENDATION、CLARIFY_NEEDED、PRODUCT_COMPARE、CHITCHAT、ORDER_CONFIRM 和 OUT_OF_SCOPE。闲聊单独走回复分支,越权请求则走拒绝分支。

缺少信息时,就轮到 ClarifyAgent。规则先检查必填条件,常见的缺失字段用模板追问,复杂表达和长尾品类再交给模型辅助生成问题。比如缺少使用场景,用户听到的应该是“平时在哪儿跑步”,而不是“请补充 scenario 字段”。

RecommendAgent 负责商品推荐,通过业务 Service 完成向量召回、属性过滤和画像重排,输出包含 productId、name、price、attributes、matchScore 的结构化结果。数据库查询、权限和筛选规则都放在 Service 中。最初这里还会调用模型生成推荐理由,后来我把理由生成合并到了 EmotionAgent,第六节会展开说。

最后由 EmotionAgent 把结果说给用户听。它接收推荐结果、用户原话和会话情绪,组织成适合 TTS 的短句。用户犹豫时,多解释一下推荐原因;用户嫌贵时,先回应价格顾虑。调整后的流程里,推荐理由和口语表达都在这一步完成,不再额外查询商品。

每个 Worker 有自己的 Prompt、Memory 和输入输出格式。Orchestrator 根据当前情况决定调用谁:闲聊直接回复,信息不足先追问,条件齐全后再推荐。

三、用 Java 状态机控制导购流程

整体上,我采用 Java 21、Spring Boot 4 和 AgentScope Java Starter。前端通过 H5 + WebAudio 采集音频,WebSocket 承载双向语音与控制事件;Paraformer 负责实时 ASR,CosyVoice 负责 TTS。AgentScope 用来组织 Worker、各自的上下文,并通过 MsgHub 和生命周期 Hook 支持协作与调用管理。

一次语音请求大致会经过这些步骤:

浏览器(H5 / WebAudio)
        │ 16 kHz PCM / WebSocket
        ▼
Paraformer 实时 ASR
        │ 识别后的用户文本
        ▼
IntentAgent:意图与槽位
        │ IntentResult
        ▼
Orchestrator:Java 状态机 + SessionState
        ├─ 推荐意图 ──> 规则判断 / ClarifyAgent
        │                      │ 条件已足够
        │                      ▼
        │              RecommendAgent
        │              (经 Service 检索、过滤、重排)
        │                      │
        │                      ▼
        │              EmotionAgent → 合规检查 → TTS
        ├─ 闲聊 ───────────────> EmotionAgent
        │                      └─→ 合规检查 → TTS
        ├─ 订单确认 ───────────> 订单业务 Service
        └─ 越权请求 ───────────> 固定拒绝模板 → TTS

合规检查:ComplianceChecker;语音合成:CosyVoice
辅助层:Redis 会话记忆 / PostgreSQL + pgvector / Agent 内部 Memory

这里的 Orchestrator 是一个 Service 级状态机。IntentAgent 用模型识别意图,Orchestrator 再依据 intent 和当前 phase,选择推荐、闲聊、确认或拒绝分支。

主要路由可以用下面的流程伪代码表示:

PRODUCT_RECOMMENDATION → 检查必填槽位 → 推荐链路 → 回复 → 合规 → TTS
CHITCHAT               → 情感回复 → 合规 → TTS
ORDER_CONFIRM          → 订单确认分支
OUT_OF_SCOPE           → 固定拒绝文案 → TTS

另有 CLARIFY_NEEDED 和 PRODUCT_COMPARE 等分支,
会结合当前会话状态处理补充条件与商品对比。

这些分支的条件比较明确,直接写成代码就能处理,也省去了一次让模型决定路由的调用。如果走错了分支,可以沿着 intent、phase 和判断条件往回查。必填条件和业务权限也集中在程序里检查,修改规则时更容易找到位置。

比如用户说“把所有商品价格降 50%”,IntentAgent 将其识别为 OUT_OF_SCOPE 后,程序直接返回固定拒绝文案,这个分支就结束了。

四、意图串场之后,怎么管理会话记忆

我遇到过一次意图串场:先说“推荐跑鞋”,再说“换个颜色”,意图模型受到上一轮 category=跑鞋 的历史影响,把当前请求重新识别成商品推荐,结果又走了一遍检索。

排查时,需要先弄清楚历史信息存在哪里。项目里有三层记忆:

层级主要职责载体
Agent 内部记忆供单个 Worker 多次调用时使用AgentScope InMemoryMemory
会话短期记忆共享近期对话摘要与业务上下文Redis
跨会话长期记忆保存用户品牌、品类、消费偏好等画像PostgreSQL

AgentFactory 按 sessionId 复用 Agent 实例,内部 Memory 会随多轮调用累积。我在 AgentMemoryPolicy 中给不同 Agent 设置了不同的保留规则:

  • 意图 Agent 每轮清空内部 Memory,再注入最近三轮的摘要,减少旧分类结果的干扰。
  • 推荐 Agent 保留最近四轮(八条消息),用于理解“刚才那双”这类表达。
  • 情感 Agent 保留二十轮(四十条消息),用较长的上下文观察语气变化。

预算、推荐顺序这些业务信息则单独存放,由 SessionStateService 等服务管理,包括当前 phase、已经确定的预算、上一次推荐的商品顺序和等待确认的操作。用户说“第二款”时,系统按保存的推荐顺序查找对应商品。

五、从需求追问到商品筛选

拆开 Agent 之后,还有一个问题:每个步骤里,到底有多少工作需要模型来做?澄清和推荐是两个比较典型的例子。

缺什么信息,就追问什么

澄清流程采用“规则优先,LLM 兜底”的方式。不同品类预设不同必填槽位:例如跑鞋需要 category、budget、scenario、runningFrequency;手表重点关心 category、budget、occasion。这组字段属于业务约定,不需要每轮重新问模型。

用户说“我想买双跑鞋”
       ↓
按品类检查必填槽位
       ↓
缺少 budget / scenario 等条件?
       ├─ 否:进入推荐
       └─ 是:优先使用追问模板
                  ↓
             复杂表达或长尾品类
                  ↓
             再由 LLM 生成追问

例如缺少场景,模板可以直接问“平时跑塑胶跑道、水泥路,还是都有?”常见品类按预设字段检查就够了;遇到规则覆盖不到的品类,再由 LLM 辅助判断信息是否充分、该怎么追问。

先筛选商品,再生成推荐理由

如果用户说“预算 500,希望缓震好一点”,纯向量检索也许会召回描述中出现了“缓震科技”的竞速跑鞋,但商品的实际缓震属性未必符合需求。语义相似不等于适合购买。

推荐链路分为四段:

需求槽位
   ↓
① pgvector + HNSW:语义召回 Top 20
   ↓
② JSONB / SQL:预算、缓震属性等硬过滤
   ↓
③ ProfileReranker:品牌偏好、历史购买、预算敏感度加权
   ↓
取 Top 3 候选
   ↓
④ EmotionAgent:合并生成推荐理由与口语回复

向量检索先找出描述相关的商品,再用价格和属性筛掉不符合条件的候选。剩下的商品交给 ProfileReranker,按用户画像做本地 Java 规则加权。这样调整品牌偏好或预算敏感度的权重时,可以直接查看计算规则。选出 Top 3 后,EmotionAgent 再解释为什么推荐它们。

商家权限贯穿上述查询过程。Service 根据当前用户的可见商家范围,通过 SqlFilterBuilder 生成参数化的 merchant_id IN (?, ?, ?) 条件,注入商品、会话和订单查询。向量召回也在这个可见范围内执行,再从中选取 Top 20 候选。

也就是说,先限定能查哪些商家的数据,再在这个范围里挑符合预算和属性要求的商品。不同 Agent 调用同一套 Service,使用相同的权限条件。

六、怎么缩短语音回复的等待时间

文字回复慢一点,用户还能看着界面等;语音导购一直不出声,就很容易让人怀疑是不是没听见。所以 ASR 要边听边识别,回复也要尽早送入 TTS,同时还得等需求确认后再开始推荐。

优化首字响应时,我首先调整了推荐路径中的串行调用。最初在商品筛选完成后,由 RecommendAgent 调用模型生成推荐理由,再交给 EmotionAgent 调整表达;后来将这两个生成步骤合并,让 EmotionAgent 直接根据商品名称、价格与属性组织推荐理由和口语回复。推荐链路继续负责提供结构化候选结果,回复生成减少了一次独立的模型调用。

接下来,EmotionAgent 通过 AgentScope 的流式接口输出文本,按句号聚合切片后送入 CosyVoice。第一句完整文本就绪后即可开始合成,后续句子继续生成,不必等整段推荐结束。

原始思路:推荐结果 → 推荐理由 LLM → 情感包装 LLM → 整段 TTS
调整思路:推荐结果 ────────────> 流式口语生成
                                     │ 第一完整句
                                     ▼
                              按句发送到 TTS
                                     │
                               边合成边播报

这里的首字响应时间,指从用户说完最后一个字,到 TTS 第一帧音频到达前端的时间。合并调用省掉了一次串行等待,按句送入 TTS 则让语音合成和后续文本生成同时进行。排查这类延迟时,把请求时间线画出来,看看每一步在等谁,比只盯着单个模型的速度更容易找到调整点。

语音处理里还有一些小细节。ASR 的 partial_result 可能被后续识别修正,拿到中间结果就提交意图,可能判断错;按句切 TTS 时,“3.5 公里”里的小数点也可能被当成句子结束。切句时就得把这种数字格式考虑进去,否则一句话会在奇怪的地方断开。

七、拆分之后,还要处理哪些工程问题

回复生成后,先经过 ComplianceChecker,再送入 TTS。它会处理绝对化用语和敏感词,例如把“最好”“保证”换成较温和的表达,必要时遮蔽敏感内容。语音一旦播出来就收不回了,所以这一步放在合成之前。

另外,拆成四个 Worker 以后,要维护的东西也多了:每个角色都有 DTO、Prompt 和 Memory,修改输入输出时还要检查上下游调用。我选择 AgentScope,主要是看中 MsgHub、生命周期 Hook 和组合编排能力,用它来组织这些调用比较方便。框架本身的约定和版本兼容问题,也需要花时间处理。

八、这次实践的收获与后续改进

回头看,这个项目里花心思的地方都很具体:用户换个说法,意图会不会跑偏;说“第二款”时,能不能找到上一轮的商品;推荐已经生成了,为什么还迟迟听不到声音。把这些问题逐个拆开,才慢慢有了现在的四个 Worker 和 Java 编排流程。

接下来,我想继续看上下文窗口对意图判断的影响,也把 ASR、模型生成和 TTS 的耗时分开记录。这样再调整调用顺序或切句方式时,就能更清楚地知道改动有没有用。