gogo-agent

行程预订智能体的功能介绍及开发

到了预订这一步,Agent 要干的是真实的写操作:调第三方平台出机票、订酒店、订火车票。以及用户如果要取消预订,也是通过BookingAgent来支持的。 BookingAgent 先看这个 Agent 怎么装出来的。文件在 age…

TL;DR

到了预订这一步,Agent 要干的是真实的写操作:调第三方平台出机票、订酒店、订火车票。以及用户如果要取消预订,也是通过BookingAgent来支持的。 BookingAgent 先看这个 Agent 怎么装出来的。文件在 age…

到了预订这一步,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 差旅单」这道硬闸门。两层配合,构成了「下单前必确认」的保障。

版本提示

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

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

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