推荐问题不是「凑几个相关问题」,一条好的推荐要同时满足四点: 1. 贴合当前阶段:助手在问选择题时,推荐的应该是具体选项,而不是干巴巴的「确定」。 2. 能一键执行:点了之后最好能直接触发后端动作(跳过重新编排),而不是又走一遍主智能体。 3. 面向用户可读:绝不能暴露 plan_roundtrip 这类工具名/内部字段。 4. 便宜且不挡路:它是「锦上添花」,绝不能拖慢主回复,也不该烧贵模型。 gogo-agent 的实现恰好是围绕这四点设计的。下面逐层拆。
触发时机:主回复完成后,异步补推
推荐问题不在主回复链路里同步生成,而是在主答案推送完、且满足条件时才异步补一条 SSE。看 ChatAgentExecutor 的收尾逻辑:
// ChatAgentExecutor#sendSuggestionsThenComplete
private void sendSuggestionsThenComplete(String latestUserText, Msg finalResult, SseEmitter emitter, String sessionId) {
// ① 挂起(等用户回复)或被打断的轮次,不推荐——此时"下一步"由用户输入决定
if (finalResult.getGenerateReason() == GenerateReason.TOOL_SUSPENDED
|| finalResult.getGenerateReason() == GenerateReason.INTERRUPTED) {
emitter.complete();
return;
}
// ② 只有正常出了最终答案,才基于「用户问题 + 助手回复」生成推荐
questionRecommendationService.recommend(latestUserText, finalResult.getTextContent())
.subscribe(
suggestions -> {
if (!suggestions.isEmpty()) {
sseNotifier.sendSuggestions(emitter, suggestions, sessionId);
}
},
error -> logger.warn("[EXECUTOR] 推荐问题生成失败 sessionId={}: {}", sessionId, error.getMessage()),
emitter::complete); // ③ 无论推荐成功与否,最后都 complete,绝不阻断主流程
}
三个设计要点: - 时机:主答案已经 sendMessage 推给前端后,才追加推荐——用户先看到答案,推荐晚几百毫秒到达完全可接受。 - 门槛:TOOL_SUSPENDED(助手正等用户回填)和 INTERRUPTED(被打断)两种状态直接跳过。这很关键——助手在等具体输入时,硬塞推荐反而干扰。 - 兜底:推荐失败只 warn 不抛错,最后一定 emitter.complete()。推荐是可降级的旁路,永远不能连累主回复。
不用 ReActAgent,用裸 Model#stream
推荐问题是「一次性、无工具、无多轮」的任务,包一层 ReActAgent 纯属浪费。所以直接调最便宜的 fastModel:
// QuestionRecommendationService
@Autowired
@Qualifier("fastModel") // qwen3.6-flash,1.2 元/百万 token,最便宜档
private Model fastModel;
// 直接 stream 单次请求,避开 ReAct 循环开销
return Mono.fromCallable(() -> fastModel.stream(messages, null, null).collectList().block())
.map(this::parseSuggestions)
.onErrorResume(e -> { // 失败即空列表,不影响主流程
logger.warn("[QuestionRecommendation] 生成推荐问题失败: {}", e.getMessage());
return Mono.just(Collections.emptyList());
});
类的 Javadoc 也写明了这个取舍:「无工具调用、无多轮推理,直接通过 Model#stream 发起单次请求,避免 ReActAgent 包装带来的额外开销」。任务简单就别上重武器——这是推荐这类高频旁路任务的性价比关键。
输入构造:把「上下文 + 可执行动作」一起喂给模型
这是整篇文档最值得学的一段。一般人做推荐只喂「用户问题 + 助手回复」,但 gogo-agent 还额外注入了一份「后端能直接执行的快速操作关键词」,让推荐从「问句」升级成「可一键触发的动作」:
// QuestionRecommendationService#buildInput
private String buildInput(String userQuestion, String assistantAnswer) {
StringBuilder sb = new StringBuilder();
if (userQuestion != null && !userQuestion.isBlank()) {
sb.append("用户问题:").append(userQuestion.trim()).append("\n\n");
}
sb.append("助手回复:").append(assistantAnswer.trim()).append("\n\n");
// ★ 关键:注入可被 ChatController 识别的"继续信号词"分组
sb.append("可被后端直接执行的快速操作关键词(点击后会跳过主智能体直接续跑当前子智能体):\n");
sb.append(ContinuationSignals.asGroupedPrompt());
// ★ 并给出"何时该用、何时不该用"的判断指引
sb.append("\n请根据助手回复的当前阶段判断是否使用上述关键词:");
sb.append("如果助手在抛出澄清/选择题,优先把助手列出的具体选项作为推荐项,不要硬塞'确定/确认';");
sb.append("如果助手在等待用户整体确认/修改/继续,再考虑从上述关键词中挑选。\n");
sb.append("请根据以上对话生成接下来的推荐问题。");
return sb.toString();
}
注入的关键词来自 ContinuationSignals,它是推荐系统与 Controller 续跑逻辑的「单一事实源」:
// ContinuationSignals(同一份词,两处复用)
public static final Map<String, List<String>> GROUPS = Map.of(
"确认/继续类", List.of("确定", "确认", "提交", "继续", "是的", "好的", "对", "好", "ok", "yes", ...),
"修改/补充类", List.of("修改", "补充", "修改一下", "补充一下"),
"取消/重新类", List.of("不对", "取消", "重新", "重新来", "再来", "再"));
为什么这一步能把「推荐」和「执行」打通?看 ChatController 的另一端——用户点了这些词发回来,会被识别为 continuation,跳过 MasterAgent 重新编排,直接复用当前子智能体续跑:
// ChatController#chat
String activeAgent = activeAgentSessionStore.getActiveAgent(sessionId);
if (activeAgent != null && isContinuation(message)) { // 命中信号词
ReActAgent targetAgent = agentRegistry.getAgent(activeAgent);
agentExecutor.executeAgent(targetAgent, ...); // 直接续跑,省一次主编排
return emitter;
}
于是形成闭环:推荐服务从同一份词表里挑动作 → 展示给用户 → 用户点击 → Controller 认得这个词 → 一键续跑。推荐不再是「又一个问题」,而是「一个能被后端直接消费的动作」。这份词表任何一端改动都要同步——ContinuationSignals 的 Javadoc 专门强调了这个「双重作用」。
提示词:用「阶段感知 + 反例」把推荐钉在上下文上
光注入关键词还不够,模型很容易犯「不管三七二十一都推个'确定'」的懒病。提示词(question-recommendation-agent-system.md)用「阶段判断 + 正反示例」把这条堵死。核心规则: 关键原则:推荐项的内容必须贴合助手回复的当前阶段,关键词只是工具,不是每条推荐都必须是关键词。 它明确区分了两种相反场景: | 助手当前在做什么 | 推荐该怎么给 | | --- | --- | | 抛选择题(「A/B/C 选哪个?」) | 推荐项就是 | | 给完方案等整体确认 | 可以用「确定 / 我再想想 / 查审批进度」 | | 追问后用户想表示收到 | 用「好的 / 继续」 | | 已是最终结果无后续(闲聊问候) | 直接返回 |
提示词还带了两个针锋相对的示例,特别是「示例 1:助手在抛出选择题(不要用"确定")」——直接把常见错误写成反例。这种「给反例」的写法比只讲正面规则有效得多。 另外还有一条硬约束护栏(对应第 0 节的第 3 点): 这防止了模型把工具名/子智能体名泄露到用户可见的推荐里。
5. 结果解析:容错到底,坏数据一律降级为空
模型输出不可控(可能带 markdown 代码块、可能格式错),解析层做了多重兜底,任何异常都返回空列表而非报错:
private List<String> parseSuggestions(List<ChatResponse> responses) {
String text = extractText(responses); // 拼接流式分片
if (text == null || text.isBlank()) return Collections.emptyList();
try {
String json = extractJsonBlock(text); // ① 剥离 ```json ``` 代码块
if (json.isBlank()) return Collections.emptyList();
JSONObject obj = JSON.parseObject(json);
JSONArray array = obj.getJSONArray("questions");
if (array == null) return Collections.emptyList();
return array.stream()
.map(Object::toString)
.filter(s -> !s.isBlank())
.limit(MAX_RECOMMENDED_QUESTIONS) // ② 最多 4 条,硬截断
.toList();
} catch (Exception e) {
logger.warn("[QuestionRecommendation] 解析推荐问题结果失败: {}, text={}", e.getMessage(), text);
return Collections.emptyList(); // ③ 解析崩了也不抛,降级为无推荐
}
}
extractJsonBlock 对「模型爱套 markdown 代码块」这个老毛病做了专门处理——先找 前缀,再退化为「取第一个 { 到最后一个 }」的兜底。推荐这种可降级功能,解析层的信条就是「宁可不推,绝不报错」。