到了预订这一步,Agent 要干的是真实的写操作:调第三方平台出机票、订酒店、订火车票。以及用户如果要取消预订,也是通过BookingAgent来支持的。
BookingAgent
先看这个 Agent 怎么装出来的。文件在 agent/BookingAgent.java,继承 BaseSubAgent:
@Configuration("bookingAgentConfiguration")
public class BookingAgent extends BaseSubAgent {
private static final int MAX_ITERATIONS = 10;
@Bean(name = "bookingAgent")
@Scope("prototype") // 每次调用取全新实例
public ReActAgent build() {
Toolkit toolkit = new Toolkit(ToolkitConfig.builder()
.parallel(true) // 允许并行工具调用(多段下单可并发)
.build());
toolkit.registration().tool(travelOrderReadTools).apply(); // 查已审批差旅单(预订闸门)
toolkit.registration().tool(bookingReadTools).apply(); // 查已有预订记录(防重复)
toolkit.registration().tool(bookingWriteTools).apply(); // cancel_booking(唯一写工具)
toolkit.registration().tool(queryUserInfoTools).apply(); // 用户/联系人信息
toolkit.registration().tool(apiKeyTools).apply(); // API Key 管理
SkillBox skillBox = buildBookingSkillBox(toolkit); // ← 下单能力在这里
ReActAgent agent = ReActAgent.builder()
.name("BookingAgent")
.description("预订执行专家,负责用户确认方案后的出票/订房/订火车票及已有预订的取消")
.model(strongModelWithThinking) // 强思考模型
.memory(AgentMemoryFactory.create(stableModel))
.longTermMemory(createLongTermMemory(memoryFactory))
.longTermMemoryMode(LongTermMemoryMode.AGENT_CONTROL)
.toolkit(toolkit)
.toolExecutionContext(createToolCtx())
.skillBox(skillBox)
.hooks(List.of(dynamicTimeInjectionHook, new AutoContextHook(), cliResultCompressHook,
flightApiKeyHook, tuniuApiKeyHook, rghUserIsolationHook,
skillContentCollapseHook, sessionPersistenceHook, executionLoggerHook,
toolCircuitBreakerHook, progressNotifierHook, activeAgentPersistenceHook,
bookingPersistenceHook, new PendingToolRecoveryHook())) // ← 落库 Hook 在这
.toolExecutionConfig(getToolConfig())
.sysPrompt(PromptLoader.loadStatic("booking-agent-system.md"))
.maxIters(MAX_ITERATIONS)
.generateOptions(getEnableThinkingGenerateOptions(2048)) // cacheControl + thinkingBudget
.build();
return agent;
}
几个和「审核 Agent」形成鲜明对比的选择: - 用强思考模型 strongModelWithThinking(不像审核用便宜的 GLM-5.1)。因为下单要从用户「确认 P1」这种模糊指令里准确提取方案信息、按顺序敲多段 CLI、还要处理各种失败分支——是需要推理的复杂执行任务。 - parallel(true)——允许并行工具调用,一次行程往往要同时订机票+酒店+火车票,可以并发发起。 - 14 个 Hook 的重型管线——这是全项目 Hook 最多的 Agent 之一,因为它要干的副作用最多:注入 API Key、用户隔离、下单落库、进度推送……(下面重点讲落库 Hook)。 - Java 写工具只有一个 cancel_booking。下单没有 Java 工具,取消却有。
✅预定时,为什么下单用skill,取消用工具?
预订智能体(BookingAgent)里有个乍看很别扭的不对称: 同一个业务域、同一套 tuniu CLI,为什么下单敢放手给模型,取消却要收回代码手里?把两条主线的真实代码摆在一起,答案会非常清晰:这不是随意,而是由"操作错了会怎样" LLMentor
下单Skill
下单能力全在这个方法里:
private SkillBox buildBookingSkillBox(Toolkit toolkit) {
SkillBox skillBox = new SkillBox(toolkit);
skillBox.registerSkill(skillRepository.getSkill("tuniu-cli")); // 只挂 tuniu-cli 一个技能
ShellCommandTool shellTool = new ShellCommandTool(
null,
Set.of("tuniu", "env", "bash", "cat", "grep", "date", "which", "ls"), // ← 命令白名单
null);
skillBox.codeExecution().withShell(shellTool).withRead().withWrite().enable();
return skillBox;
}
就是说,酒店、机票、火车票等实际的预定,我们是通过tuniu-cli这个skill实现的。
✅市面上可用的旅行相关skill/mcp工具对比
在我们的项目中,我们需要查询酒店、机票、火车票,并且还需要做预订、取消等等,这些我们没办法自己做,需要依赖第三方能力。 在以前,我们只能调第三方接口,但是ai时代,我们可以通过第三方的skill或者mcp来实现,我找遍了市面上的所有旅行服务 LLMentor TuniuApiKeyHook 动态改写
✅如何实现API KEY的动态管理与加密、脱敏
gogo-agent 是一个差旅智能体,它需要调用多个第三方服务(机票搜索、酒店预订、火车票查询),每个服务都需要 API Key。这带来了一组工程挑战: gogo-agent 设计了一套四层防护体系来解决这些问题:加密存储 → 动态注入 LLMentor 下单要带 TUNIU_API_KEY,但这个 Key 是每个用户各自的、加密存在库里的。gogo-agent 不让 LLM 碰 Key,而是用一个 Hook 在命令真正执行前动态注入。TuniuApiKeyHook 监听 PreActingEvent,发现命令里含 tuniu 且还没注入 Key 时,把命令改写成: env TUNIU_API_KEY=<该用户解密后的key> tuniu call flight saveOrder -a '{...}' LLM 全程只写 tuniu call ...,Key 由框架侧偷偷加上。这既保证了多租户隔离,又避免了敏感凭证进入模型上下文。
下单前置检查
预订是花钱的操作,绝不能随便触发。系统提示词 booking-agent-system.md 里定了一条不可跳过的强制检查: 这是一个很关键的架构决策:预订 Agent 本身不做差旅政策合规校验(它的工具箱里根本没有 PolicyTools,提示词也不提 check_travel_policy)。合规、预算、超标这些把关,全部前移到了规划/审核/审批环节。到了预订这一步,逻辑被简化成一句话:只要有一张 APPROVED 的差旅单,就说明这趟行程已经通过了所有合规检查,可以放心下单。 换句话说,超标的行程根本走不到预订这一步——它会在审批环节被拦下,拿不到 APPROVED 状态,于是预订 Agent 直接拒绝。这种「把关集中在上游、下游只认审批结果」的设计,让预订 Agent 的职责非常干净。 提示词里还有第二道防线——防重复下单:执行下单前先调 query_booking_record 查有没有相同行程的已有预订,避免重复出票。
下单结果落库
CLI 下单成功后,订单信息怎么可靠地进数据库? 一个天真的做法是:让 LLM 下单成功后,再调一个「保存订单」的 Java 工具。但这很脆弱——模型可能忘了调、可能把参数填错、还白白多消耗一轮推理和 token。 gogo-agent 的做法是在框架层用 Hook 拦截 shell 执行结果,对 LLM 完全透明地自动落库。BookingPersistenceHook 监听 PostActingEvent(工具执行完成后触发)
@Override
public <T extends HookEvent> Mono<T> onEvent(T event) {
if (event instanceof PostActingEvent postActing) {
try {
handlePostActing(postActing);
} catch (Exception e) {
logger.error("[BookingPersistence] 预订记录落库异常", e); // 落库失败不得影响主流程
}
}
return Mono.just(event);
}
它的处理流程是: 第一步,识别是不是下单/取消命令。 用命令字符串匹配来判断操作类型和业务类型:
private static final String CMD_FLIGHT_CREATE = "call flight saveOrder";
private static final String CMD_HOTEL_CREATE = "call hotel tuniuHotelCreateOrder";
private static final String CMD_TRAIN_CREATE = "call train bookTrain";
private static final String CMD_FLIGHT_CANCEL = "call flight cancelOrder";
private static final String CMD_TRAIN_CANCEL = "call train cancelOrder";
// resolveOp(command):命令含哪个片段 → 返回 (CREATE/CANCEL, FLIGHT/HOTEL/TRAIN);非预订命令返回 null
不是预订命令就直接跳过,所以 cat、ls 这些辅助命令不受影响。 第二步,只在成功时同步,且能解开 MCP 嵌套结构。 途牛 CLI 返回的业务 JSON 有时被包在 MCP 的 content[].text 字符串里,Hook 会先解包再取外部单号:
// 业务结果通常在 result 内层
JSONObject result = resultJson.getJSONObject("result");
if (result == null) result = resultJson;
result = unwrapMcpContent(result); // 解开 content[].text 里的嵌套业务 JSON
// 容错多种字段命名提取外部单号
String externalOrderNo = firstString(result,
"orderId", "orderNo", "mainOrderNo", "bookingOrderId", "order_id");
if (externalOrderNo == null || externalOrderNo.isBlank()) return; // 没单号不落库
第三步,组装 BookingRecord,幂等落库。 从会话上下文拿 userId、travelOrderId(关联到差旅单),新订单状态一律 CREATED + paymentStatus=UNPAID;落库前先按「平台+类型+外部单号」查重,保证幂等:
// 幂等:同平台 + 类型 + 外部单号已存在则不重复落库
if (bookingRecordRepository.findByExternalOrder(PLATFORM_TUNIU, bizType, externalOrderNo).isPresent()) {
return;
}
BookingRecord record = new BookingRecord();
record.setUserId(userId);
record.setTravelOrderId(sessionCtx.getTravelOrderId()); // 关联差旅单
record.setBizType(bizType);
record.setPlatform(PLATFORM_TUNIU);
record.setExternalOrderNo(externalOrderNo);
record.setStatus(BookingStatus.CREATED);
record.setPaymentStatus("UNPAID");
record.setBookedAt(Instant.now());
enrichFromArgs(record, bizType, args); // 从下单入参补全标题/时间/联系人/金额
bookingRecordRepository.save(record);
注意 enrichFromArgs——它从 CLI 命令里 -a '{...}' 那段入参 JSON 里回填展示字段(机票的出发到达城市+航班号、酒店的入离店日期、火车的车次价格、联系人等)。也就是说,展示信息不依赖平台返回,而是从「我们发出去的下单请求」里取,更可控。 各平台返回结构差异收敛在本 Hook(Java 侧适配),skill 保持工具无关。对 LLM 完全透明,不占用 token。 也就是说,脏活累活(解析五花八门的平台返回、字段容错、落库幂等)都由 Java Hook 扛下,技能文档保持纯净,LLM 也不用操心落库这件事。
支付:不自动扣款,只推支付链接给用户
支付是很敏感的操作,Agent 该自动扣款吗?答案是坚决不。 整个项目没有任何支付网关配置、没有 PaymentTools、没有 pay 工具。下单只创建订单,状态停在 CREATED / 待支付。落库成功后,Hook 会往前端推一个 booking_result SSE 事件,把支付链接带过去:
private void emitBookingResult(String sessionId, BookingRecord record, JSONObject result) {
// 支付/详情链接:容错多种字段命名
String payUrl = firstString(result,
"payUrl", "paymentUrl", "cashierUrl", "orderDetailH5Url", "orderDetailUrl", "jumpUrl");
Map<String, Object> data = new LinkedHashMap<>();
data.put("type", "booking_result");
data.put("bizType", record.getBizType().name().toLowerCase());
data.put("orderId", record.getExternalOrderNo());
data.put("title", record.getTitle());
data.put("amount", record.getTotalAmount() != null ? record.getTotalAmount().toPlainString() : null);
data.put("payUrl", payUrl);
data.put("statusLabel", "待支付");
emitter.send(SseEmitter.event().name("booking_result").data(JSON.toJSONString(data)));
}
前端收到这个事件后渲染一张带「立即支付」按钮的支付卡片,用户点进去在途牛 App / 小程序里自己完成付款。 为什么不让 LLM 在回复里把支付链接打出来?因为模型可能改写、截断、甚至幻觉出错误的 URL。支付链接这种「一个字符都不能错」的东西,走确定性的结构化 SSE 事件推送,比让模型复述可靠得多。这是「关键信息不经过 LLM 转述」的典型工程取舍。 所以整体定性是:下单是真的(真调途牛平台,需要有效的 TUNIU_API_KEY),但支付是延迟给用户的,系统不碰钱,也没有 mock 支付这一步。
预订 Agent 是 MasterAgent 的工具
预订 Agent 不由路由器直接分发,而是作为 MasterAgent 的子代理工具注册,工具名 booking_agent:
// MasterAgent.java —— 把预订子智能体注册成一把工具
toolkit.registration()
.subAgent((SubAgentProvider<ReActAgent>) () ->
context.getBean("bookingAgent", ReActAgent.class), // 懒加载 prototype 实例
SubAgentConfig.builder()
.toolName("booking_agent")
.description("调用 BookingAgent 处理用户确认方案后的预订执行(出票/订房/订火车票)及已有预订的取消。" +
"参数:message(含用户确认的方案编号或取消指令),可选 session_id(继续会话)。")
.forwardEvents(false)
.build())
.apply();
MasterAgent 根据意图路由:用户说「确认 P1」「就选这个」「帮我定了」→ 识别为 booking 意图 → 调 booking_agent;说「取消预订」「退票」→ 同样路由到它。完整业务链路是:申请 → 审批 → 规划(ItineraryPlanAgent)→ 预订(BookingAgent)→ 报销。 关于「预订前的用户确认」,这里有个分工要说清楚:确认动作发生在 MasterAgent 层,不在预订 Agent 内部。MasterAgent 有一把 HITL 工具 ask_user(UserInteractionTools),它通过抛 ToolSuspendException 挂起 Agent、让前端弹确认框:
@Tool(name = "ask_user", description = "...'confirm' for yes/no questions...")
public String askUser(...) {
throw new ToolSuspendException(reason); // 挂起 Agent,前端渲染 confirm/select/form UI
}
预订 Agent 信任「MasterAgent 只在用户确认后才路由过来」这个契约,自己不再弹确认框,但额外守住「必须有 APPROVED 差旅单」这道硬闸门。两层配合,构成了「下单前必确认」的保障。