gogo-agent

如何基于回答更好的问题推荐?

推荐问题不是「凑几个相关问题」,一条好的推荐要同时满足四点: 1. 贴合当前阶段:助手在问选择题时,推荐的应该是具体选项,而不是干巴巴的「确定」。 2. 能一键执行:点了之后最好能直接触发后端动作(跳过重新编排),而不是又走一遍主智…

TL;DR

推荐问题不是「凑几个相关问题」,一条好的推荐要同时满足四点: 1. 贴合当前阶段:助手在问选择题时,推荐的应该是具体选项,而不是干巴巴的「确定」。 2. 能一键执行:点了之后最好能直接触发后端动作(跳过重新编排),而不是又走一遍主智…

推荐问题不是「凑几个相关问题」,一条好的推荐要同时满足四点: 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 代码块」这个老毛病做了专门处理——先找 前缀,再退化为「取第一个 { 到最后一个 }」的兜底。推荐这种可降级功能,解析层的信条就是「宁可不推,绝不报错」。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。