gogo-agent

行程审核智能体的功能介绍及开发

差旅系统里,行程规划 Agent(ItineraryPlanAgent)会用一个强模型(qwen3.7 max + 深度思考)一次生成好几套候选方案:综合最佳、时间最短、价格最低……但「生成」和「把关」是两种截然不同的认知任务。 生…

TL;DR

差旅系统里,行程规划 Agent(ItineraryPlanAgent)会用一个强模型(qwen3.7 max + 深度思考)一次生成好几套候选方案:综合最佳、时间最短、价格最低……但「生成」和「把关」是两种截然不同的认知任务。 生…

差旅系统里,行程规划 Agent(ItineraryPlanAgent)会用一个强模型(qwen3.7-max + 深度思考)一次生成好几套候选方案:综合最佳、时间最短、价格最低……但「生成」和「把关」是两种截然不同的认知任务。 生成是发散的——要权衡偏好、创造性地组合航班和酒店;把关是收敛的——要拿客观标尺一条条量:日期填全了没?中转时间够不够转机?酒店超没超差旅限额?总价超没超审批金额?城市顺序有没有来回折返? 如果让同一个 Agent 既当运动员又当裁判,有两个现实问题。一是认知污染:模型刚生成完方案,让它自评,很容易「自己夸自己」,倾向于给自己的作品放水。二是成本浪费:把关本质上是照着规则表打勾的机械活,用那个又贵又慢的强思考模型来做,是杀鸡用牛刀。 gogo-agent 的做法是把审核拆成一个独立的子代理 ItineraryReviewAgent: - 换一个更便宜的模型(stableModel = GLM-5.1,非思考模式),因为审核靠的是结构化工具算出来的硬指标,不需要强推理; - 换一套更轻的配置(maxIters 8、只挂 3 个 Hook、不接 MCP / RAG / SkillBox),因为它职责单一; - 用一个只读的结构化校验工具把「五个维度怎么量」固化进 Java 代码,而不是让模型凭感觉判断。 最关键的一步棋是它的调用方式:审核 Agent 不由顶层的 MasterAgent 或路由器分发,而是被规划 Agent「当成一把工具」直接调用。规划 Agent 生成方案 → 调审核工具 → 拿回报告 → 按建议改 → 再审,形成一个自我修正的闭环。这个「子代理即工具」(sub-agent as tool)的 handoff 模式,是这篇文档最值得记住的一件事。下面逐层看真实实现。 注册到ItineraryPlanAgent中 前面都在讲审核 Agent 自己,现在看它怎么被驱动。答案不在什么路由器里,而在规划 Agent ItineraryPlanAgent 的工具注册处——审核 Agent 是被当成一把工具注册进去的:

// ItineraryPlanAgent.java —— 把审核子智能体注册成一把工具
toolkit.registration()
        .subAgent((SubAgentProvider<ReActAgent>) () ->
                        applicationContext.getBean("itineraryReviewAgent", ReActAgent.class), // 懒加载,每次取全新 prototype 实例
                SubAgentConfig.builder()
                        .toolName("itinerary_review_agent")
                        .description("委托行程审核子智能体对行程方案进行五维结构化审核(完整性/时间/出发目的地/预算/偏好/路径)," +
                                     "返回审核报告与具体修改建议。参数:message(包含审核所需的上下文说明,如政策/偏好)。")
                        .forwardEvents(false)
                        .build())
        .apply();

这就是 AgentScope 1.0.12 的 SubAgentProvider + SubAgentConfig「子代理即工具」模式。几个要点: - SubAgentProvider 是个懒加载工厂 () -> applicationContext.getBean(...),每次调用才从 Spring 容器取一个全新的 prototype 审核 Agent——这解释了第二节的 @Scope("prototype")。 - 对规划 Agent 而言,审核 Agent 就是工具清单里一把叫 itinerary_review_agent 的工具,参数只有一个 message。它不需要知道审核内部有五个维度、用什么模型——完全黑盒。 - forwardEvents(false) 表示审核子代理内部的事件流不往上层透传,只返回最终报告。 于是整条链路是这样跑的:

ItineraryReviewAgent

先看这个 Agent 是怎么装出来的。文件在 agent/ItineraryReviewAgent.java,它继承共享基类 BaseSubAgent:

@Configuration("itineraryReviewAgentConfiguration")
public class ItineraryReviewAgent extends BaseSubAgent {

    /** 行程审核 Agent 最大推理迭代次数 */
    private static final int MAX_ITERATIONS = 8;

    @Autowired
    protected ItineraryReviewTools itineraryReviewTools;

    @Bean(name = "itineraryReviewAgent")
    @Scope("prototype")                              // 每次请求都拿一个全新实例,避免会话串味
    public ReActAgent build() {
        Toolkit toolkit = new Toolkit();
        // 只挂两把工具:政策查询 + 五维结构化校验
        toolkit.registration().tool(policyTools).apply();          // query_travel_policy / check_travel_policy
        toolkit.registration().tool(itineraryReviewTools).apply(); // review_planner_result

        ReActAgent agent = ReActAgent.builder()
                .name("ItineraryReviewAgent")
                .description("行程方案审核专家,对规划好的行程进行五维结构化审核(完整性/时间/出发目的地/预算/路径),输出审核报告和具体修改建议")
                // 规划和审核使用不同模型 —— 审核用更便宜的 GLM-5.1
                .model(stableModel)
                .toolkit(toolkit)
                .toolExecutionContext(createToolCtx())             // 从 ThreadLocal 注入当前用户会话上下文
                // 自动压缩/卸载大工具结果与历史,抑制多轮输入 token 膨胀(需搭配 AutoContextHook)
                .memory(AgentMemoryFactory.create(stableModel))
                .longTermMemory(createLongTermMemory(memoryFactory))
                .longTermMemoryMode(LongTermMemoryMode.AGENT_CONTROL)
                .hooks(List.of(new AutoContextHook(), executionLoggerHook, progressNotifierHook))
                .sysPrompt(PromptLoader.load("itinerary-review-agent-system.md"))
                .toolExecutionConfig(getToolConfig())              // 超时 1min,失败重试 3 次(仅 IOException)
                .maxIters(MAX_ITERATIONS)
                .build();

        return agent;
    }
}

对照规划 Agent(ItineraryPlanAgent,qwen3.7-max、maxIters 15、约十来个 Hook、带 PlanNotebook、接 MCP),审核 Agent 明显是个「轻量裁判」: | 维度 | 规划 Agent | 审核 Agent | | --- | --- | --- | | 模型 | strongModelWithThinking | stableModel | | maxIters | 15 | 8 | | 工具 | 规划工具 + 天气/签证 MCP + 子代理们 | 仅 | | Hook 链 | 十余个(含时间注入、熔断、CLI 压缩…) | 3 个(AutoContext + 日志 + 进度) | | RAG / MCP / SkillBox | 有 | 全无 |

这套「按职责选模型和配置」的分层思路很务实——贵模型只花在真正需要创造力的地方,机械校验交给便宜模型 + 硬编码规则。

review_planner_result 五维结构化校验

审核 Agent 智能的部分其实很薄,真正的把关逻辑固化在一把只读工具里:ItineraryReviewTools.review_planner_result。这是整套设计的核心——用 Java 代码把「怎么量」写死,模型只负责调用它、读结果、组织成人话。 不传方案 JSON,自己从 Redis 读

@Tool(
        name = "review_planner_result",
        description = "从行程规划工具(plan_itinerary)存入 Redis 的规划结果中读取多方案并批量结构化审核" +
                      "(五维:行程完整性/时间/出发目的地/预算/路径,不含个人偏好)。" +
                      "规划结果按用户隔离存储在 Redis 中,无需也无法传入存储位置。" +
                      "工具直接从 Redis 读取解析,无需把庞大的方案 JSON 作为参数传入,避免上下文膨胀与 JSON 解析失败。" +
                      "返回 reviews 数组(每项含 proposal_id、overall_status、checks、issues、suggestions)、best_proposal_id 与 summary。"
)
public String reviewPlannerResult(
        @ToolParam(name = "policy",
                description = "差旅政策JSON(hotel_limit_per_night / transport_class / total_budget_limit 等)",
                required = false)
        String policy,
        AgentSessionContext sessionCtx) {

        String userId = sessionCtx != null ? sessionCtx.getUserId() : null;
        String redisKey = ItineraryPlanStore.keyOf(userId);       // 按用户隔离的 Redis Key
        // ... 从 Redis 读取 plan_itinerary 写入的方案 JSON ...
        String content = itineraryPlanStore.load(userId);
        // content 为 null / 读取失败 → 返回结构化 fail,提示"确认 plan_itinerary 已成功执行"
}

这里有一个很关键的工程决策:方案 JSON 不作为参数传入,工具自己按当前用户从 Redis 里捞。 规划 Agent 生成的多套方案往往是一大坨 JSON(航班、酒店、评分、指标……)。如果让模型把这坨东西当参数传给审核工具,会撞两堵墙:一是上下文爆炸(这坨 JSON 要占大量 token),二是JSON 易解析失败(模型手工拼 JSON 经常拼错、截断)。 解法是用户级隔离的 Redis 中转:规划工具 plan_itinerary 把结果写进 ItineraryPlanStore.keyOf(userId),审核工具凭会话上下文里的 userId 直接读回来。模型全程不碰这坨数据,只需要说一句「审吧」。这就是提示词里反复强调「你不需要、也无法传入路径 / 方案 JSON」的原因。

✅ReviewAgent如何获取到PlanAgent的规划结果?

ReviewAgent和PlanAgent之间需要传递方案,但是我们不是通过参数传递,而是通过 Redis 中转。PlanAgent 把规划结果写进 Redis,ReviewAgent 从同一个 key 读出来。(如果不考虑集群部署,可以考 LLMentor 核心裁判逻辑 拿到方案后,每套方案都跑一遍 reviewSingle,依次做五项独立检查:

private JSONObject reviewSingle(JSONObject itineraryJson, JSONObject policyJson) {
    List<CheckResult> checks = new ArrayList<>();
    List<String> issues = new ArrayList<>();
    List<String> suggestions = new ArrayList<>();

    checkCompleteness(itineraryJson, checks, issues, suggestions);      // 1. 行程完整性
    checkTimingValidity(itineraryJson, checks, issues, suggestions);    // 2. 时间合理性
    checkOriginDestination(itineraryJson, checks, issues, suggestions); // 3. 出发地/目的地核实
    checkBudgetCompliance(itineraryJson, policyJson, checks, issues, suggestions); // 4. 预算合规
    checkRouteRationality(itineraryJson, checks, issues, suggestions);  // 5. 路径合理性

    String overallStatus = computeOverallStatus(checks);
    // 组装成 {overall_status, checks[], issues[], suggestions[]}
}

每个维度产出一个 CheckResult(维度名, 状态, 明细),状态是 pass / warning / fail 三档。逐个看它们怎么量(都是硬编码的、可解释的规则,不是模型拍脑袋): ① 行程完整性 —— 出发地/目的地/去程日期/返程日期是否填全;days 是否为空;是否有去程交通、返程交通;除最后一天外每晚是否都安排了住宿。任一缺失即 fail。

if (isBlank(itinerary.getString("origin")))       problems.add("出发地(origin)未填写");
// ...
// 最后一天不需要住宿(当天返回)
if (i < days.size() - 1 && !day.containsKey("hotel")) nightsWithoutHotel++;

② 时间合理性 —— 三个缓冲检查,阈值都是常量写死的:

private static final int MIN_DOMESTIC_TRANSFER_MINUTES = 60;  // 国内中转
private static final int MIN_INTL_TRANSFER_MINUTES = 90;      // 国际中转
private static final int MIN_ARRIVAL_BUFFER_MINUTES = 60;     // 到达→首个活动
private static final int MIN_RETURN_BUFFER_MINUTES = 60;      // 末个活动→返程出发

到达时间与首个活动缓冲 < 60 分钟告警;返程出发早于最后活动直接判「时间冲突」(升级为 fail),缓冲不足 60 分钟则告警;国内中转 < 60、国际中转 < 90 分钟告警。注意状态升级逻辑:warnings 里只要有一条含「冲突」二字就整体判 fail,否则 warning。 ③ 出发地/目的地核实 —— 拿方案里的实际出发地/目的地对照「审批单」里的 approved_origin / approved_destination。匹配用的是包含式(任一方包含另一方即算一致),且无审批信息时不校验:

private boolean cityMatches(String actual, String approved) {
    if (approved == null || actual == null) return true; // 无审批信息时不做校验
    return actual.contains(approved) || approved.contains(actual);
}

这是为了兼容「北京」vs「北京市」「上海」vs「上海虹桥」这类写法差异。不一致直接 fail——毕竟去了审批外的城市是硬违规。 ④ 预算合规性 —— 两道线:每晚酒店价对照政策 hotel_limit_per_night,行程总价对照审批 total_budget_limit。没传政策就跳过并告警:

if (policy.isEmpty()) {
    checks.add(new CheckResult("预算合规性", "warning", "未提供差旅政策,跳过预算校验"));
    suggestions.add("建议调用 check_travel_policy 获取差旅政策后再做预算校验");
    return;
}
// 逐晚酒店超限 & 总价超批准金额 → 记入 overBudget

值得注意的是超预算判的是 warning 而非 fail——因为差旅里超标常有正当理由(旺季、指定酒店),工具给的建议是「需在方案说明中注明原因,或调整为更经济的选项」,把最终裁量权留给人。 ⑤ 路径合理性 —— 两个检查:A→B→A 折返检测(城市序列里隔一位出现同名城市即告警),以及酒店距主要活动场所 > 15km 告警:

private static final double MAX_HOTEL_DISTANCE_KM = 15.0;
// checkCityBacktrack:citySequence.get(i).equals(citySequence.get(i + 2)) → 折返
// checkHotelDistance:distance_to_main_venue_km > 15.0 → 建议选更近住宿

综合评级与选出「最优方案」 单方案的 overall_status 采用短板优先:有 fail 则 fail,有 warning 则 warning,全 pass 才 pass。

private String computeOverallStatus(List<CheckResult> checks) {
    boolean hasFail = checks.stream().anyMatch(c -> "fail".equals(c.status()));
    boolean hasWarning = checks.stream().anyMatch(c -> "warning".equals(c.status()));
    if (hasFail) return "fail";
    return hasWarning ? "warning" : "pass";
}

选「最优方案」best_proposal_id 时,把审核状态转成权重(pass=1.0 / warning=0.7 / fail=0.0),乘以规划阶段给出的综合分:

private int computeEffectiveScore(String status, JSONObject plannerScores) {
    double weight = switch (status) {
        case "pass" -> WEIGHT_PASS;      // 1.0
        case "warning" -> WEIGHT_WARNING;// 0.7
        default -> WEIGHT_FAIL;          // 0.0
    };
    double base = (plannerScores != null && plannerScores.getDouble("overall") != null)
            ? plannerScores.getDouble("overall") : 0.0;
    return (int) Math.round(base * weight);
}

这个设计很巧:审核不重新给方案打分,而是给规划的分数打折。一套规划分很高但审核 fail 的方案,weight=0 直接出局;warning 的打 7 折。最终选出的是「既好又合规」的那套。所有方案的 issues / suggestions 会带上 [P2] 这样的方案号前缀汇总,方便规划 Agent 定位改哪套。

版本提示

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

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

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