差旅系统里,行程规划 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 定位改哪套。