最近看到一组关于“携程 Agent 应用开发一面”的技术问题,内容非常集中:Text2SQL、三层 RAG、PGVector 选型、多轮上下文、Prompt Token 控制、SQL 安全校验、准确率评估,以及权益系统里的 MQ、幂等和最终一致性。
这些问题表面上是在问 AI Agent,实际上考察的是一个更完整的能力:
你能不能把 LLM 从 Demo 做成一个可上线、可维护、可回滚、可评估、可控风险的业务系统。
这篇文章就围绕这些问题,整理成一篇完整的技术复盘。
1. 项目背景:业务数据智能查询平台
假设我们要做一个业务数据智能查询平台,用户可以用自然语言提问,例如:
“上个月华东区域出境票量是多少?”
“按渠道分组看一下近 7 天酒店订单 GMV。”
“对比一下本月和上月权益核销失败率。”
系统需要自动完成:
- 理解用户问题;
- 识别业务对象、时间范围、指标、维度和筛选条件;
- 检索相关表结构、业务规则、历史 SQL 案例;
- 生成 SQL;
- 做 SQL 安全和质量校验;
- 执行查询;
- 返回结果和解释。
这类系统不能只靠一个大模型直接生成 SQL,因为真实企业环境里有很多复杂问题:
- 表很多,字段命名不统一;
- 同一个指标在不同业务线含义不同;
- 数据库里没有完整业务说明;
- 用户会多轮追问;
- SQL 可能有权限、性能和安全风险;
- 生成结果必须可评估、可追溯、可修正。
所以一个可落地的 Text2SQL Agent,核心不是“让模型写 SQL”,而是搭建一套围绕 LLM 的工程系统。
2. 总体架构:Agent 编排 + 三层 RAG + SQL 校验闭环
一个比较稳妥的架构可以拆成下面几层:
用户问题
↓
Query Rewrite / 意图识别
↓
上下文管理
↓
三层 RAG 检索
├─ DDL Schema 知识
├─ 业务规则知识
└─ Few-shot SQL 案例
↓
Prompt 组装
↓
LLM 生成 SQL
↓
SQL 安全校验
↓
SQL 语义 / 字段 / 权限校验
↓
执行查询
↓
结果解释
↓
用户反馈
↓
样本沉淀与知识库更新这套系统真正难的地方,不是调用 LLM API,而是下面几个工程问题:
- 如何组织 DDL、业务规则和 Few-shot?
- 如何避免 Prompt 太长?
- 如何处理多轮对话?
- 如何保证 SQL 安全?
- 如何评估 Text2SQL 准确率?
- 如何在业务系统里保证消息链路和状态一致性?
3. 为什么 Agent 编排不一定用 LangChain 或 AutoGen?
很多人一做 Agent 就想到 LangChain、AutoGen、CrewAI 这类通用框架。但在业务数据查询平台里,不一定要上来就用通用框架。
更合理的选择是:核心链路自己编排,LLM 只是链路中的一个能力节点。
原因有三个。
第一,Text2SQL 的流程非常固定。它通常就是:
Query Rewrite → RAG → Prompt → SQL Generate → SQL Validate → Execute → Explain这个流程不像开放式 Agent 那样需要复杂的工具自主规划。过度使用通用 Agent 框架,反而会让链路变得不可控。
第二,SQL 风险必须强控制。比如不能让模型随意执行:
UPDATE
DELETE
DROP
TRUNCATE
ALTER也不能让模型跨权限访问表。通用框架强调灵活性,但业务查询系统更需要确定性、审计性和边界控制。
第三,企业系统通常已有自己的 Java 服务、权限体系、日志链路、监控系统和审批流程。如果强行套框架,后续接入成本和排障成本会比较高。
所以更实际的做法是:
- Java / Spring Boot 负责主链路;
- Redis 管理会话状态;
- MySQL / PostgreSQL 管理业务元数据;
- PGVector 做向量检索;
- LLM 负责 Query Rewrite、SQL 生成和解释;
- 自研编排层控制状态、权限、日志和失败补偿。
4. 三层 RAG:DDL、业务规则、Few-shot 分开治理
Text2SQL 的 RAG 不能简单地把所有资料塞进一个知识库。更好的方式是拆成三层。
第一层:DDL Schema 知识
DDL 层主要包含:
- 表名;
- 字段名;
- 字段类型;
- 主键外键;
- 表之间的关系;
- 分区字段;
- 常用过滤字段;
- 字段枚举值;
- 表的业务说明。
例如:
表:hotel_order_fact
说明:酒店订单事实表
字段:
- order_id:订单 ID
- user_id:用户 ID
- city_id:城市 ID
- channel:下单渠道
- order_amount:订单金额
- order_date:订单日期
- status:订单状态DDL 层解决的是:模型知道有哪些表和字段可以用。
第二层:业务规则知识
业务规则层解决的是:字段怎么解释,指标怎么算。
GMV = 已支付订单金额,不包含取消订单。
出境票量 = 国际机票中 departure_country != 'CN' 的订单数量。
有效权益核销 = 核销状态为 SUCCESS,且没有发生退款、撤销或补偿回滚。这类规则往往不在数据库里,而是在产品文档、业务说明、运营口径、历史报表和人脑里。
如果不把业务规则纳入 RAG,模型即使写出语法正确的 SQL,也很可能口径错误。
第三层:Few-shot SQL 案例
Few-shot 层保存高质量历史案例:
问题:统计上个月华东区域出境票量
SQL:
SELECT COUNT(DISTINCT order_id)
FROM flight_order_fact
WHERE region = '华东'
AND departure_country != 'CN'
AND order_date >= '2025-05-01'
AND order_date < '2025-06-01'
AND status = 'PAID';
解释:出境票量按已支付国际机票订单统计。Few-shot 的价值不是让模型复制 SQL,而是让模型学习企业内部的 SQL 风格、指标口径和 Join 习惯。
三层知识最后都会被转成 Embedding,写入 PGVector,并带上 metadata,例如:
knowledge_type: DDL / business_rule / few_shot
domain: hotel / flight / coupon / rights
table_name: xxx
metric_name: xxx
version: 2025-06-01
owner: data_team
status: active5. DDL 导入如何保证准确?
DDL 不应该直接让 LLM 猜。
更稳妥的方式是从数据库元数据中自动抽取,然后人工补充业务说明。来源可以包括:
information_schema;- 数据血缘平台;
- 数据字典;
- BI 报表字段说明;
- 指标平台;
- 数据开发文档;
- 人工维护的表说明。
DDL 入库流程可以是:
数据库元数据扫描
↓
字段清洗与标准化
↓
表关系识别
↓
业务说明补充
↓
生成结构化文档
↓
Embedding
↓
写入 PGVectorDDL 只负责结构,不负责业务口径。
数据库里没有业务规则时,不能让模型自己猜。应该把缺失规则标记出来,进入人工维护流程。
例如模型遇到问题:“统计有效权益核销数。”但知识库里没有“有效权益核销”的定义,系统不应该直接生成 SQL,而应该返回:
当前缺少“有效权益核销”的业务口径,请确认:
1. 是否只统计 SUCCESS 状态?
2. 是否排除退款订单?
3. 是否排除补偿核销?
4. 是否按核销时间还是订单时间统计?这比“猜一个 SQL”更安全。
6. 表和字段变更后,知识库怎么更新?
业务数据库经常变更:新增字段、字段改名、字段废弃、表拆分、表合并、指标口径变化。如果 RAG 知识库不更新,模型就会继续引用旧字段。
因此需要做 DDL Diff。
定时扫描数据库元数据
↓
与上一版 DDL 比较
↓
识别新增、删除、修改字段
↓
重建受影响文档
↓
更新 Embedding
↓
旧版本标记为 deprecated需要特别注意:不要只新增新知识,还要让旧知识失效。
一个常见事故是:
旧字段:coupon_status
新字段:rights_status如果旧的 embedding 还在,模型可能继续生成:
WHERE coupon_status = 'SUCCESS'但这个字段已经不存在。
所以知识库里最好有状态字段:
status: active / deprecated / deleted
version: 2025-06-01
effective_time: xxx检索时只召回 active 的知识,历史版本只用于审计和回溯。
7. 为什么向量检索选择 PGVector?
在中小规模业务知识库里,PGVector 是一个很务实的选择。
第一,很多企业本来就有 PostgreSQL。使用 PGVector 可以把结构化元数据和向量数据放在一起管理。
第二,DDL、业务规则、Few-shot 案例通常不是海量互联网文档。知识规模在几千到几十万条以内时,PGVector 已经足够。
第三,PGVector 支持 HNSW 索引,检索速度对于企业内部知识库来说通常够用。
第四,metadata 过滤比较方便。比如只查某个业务域:
SELECT *
FROM rag_knowledge
WHERE domain = 'rights'
AND knowledge_type IN ('DDL', 'business_rule', 'few_shot')
ORDER BY embedding <=> :query_embedding
LIMIT 10;为什么不用 Milvus?
Milvus 更适合大规模向量场景,比如千万级、亿级向量检索。但如果知识库不大,单独维护 Milvus 的部署、监控、备份和权限成本会更高。
为什么不用 Elasticsearch?
Elasticsearch 更强在关键词检索、倒排索引和日志搜索。它可以做 hybrid search,但如果系统已经使用 PostgreSQL,且向量规模不大,PGVector 的工程复杂度更低。
小到中等规模知识库:PGVector
大规模向量检索:Milvus / Weaviate / Vespa
强关键词搜索:Elasticsearch
混合检索:BM25 + Vector + Rerank8. 相似度阈值为什么可能设为 0.7?
RAG 检索不能只看 Top K,还要看相似度阈值。
相似度 < 0.6:召回内容噪声较多
相似度 0.7 左右:召回与误召回较平衡
相似度 > 0.8:过于严格,可能漏掉相关知识阈值不是拍脑袋定的,而应该通过离线验证集调出来。
可以构建一批测试问题:
问题 → 标准表 → 标准字段 → 标准业务规则 → 标准 SQL然后分别测试 0.6、0.7、0.8 的召回效果:
- Recall:该召回的知识有没有召回?
- Precision:召回内容里噪声多不多?
- SQL Accuracy:最终 SQL 是否正确?
- Execution Accuracy:SQL 执行结果是否和标准答案一致?
如果 0.6 噪声太多,0.8 漏召回明显,那么 0.7 就是一个折中点。
9. 三层 RAG 同时召回,怎么控制 Token?
一个常见错误是:把 DDL、业务规则、Few-shot 全部塞进 Prompt。
这会造成三个问题:
- Token 成本高;
- 无关知识干扰模型;
- SQL 更容易生成错误 Join 或错误字段。
更好的方式是分层召回、限量注入。例如:
DDL:最多召回 5 张相关表
业务规则:最多召回 5 条相关规则
Few-shot:最多召回 3 个相似案例Prompt 可以这样组织:
你是企业数据查询 SQL Agent。
用户问题:
{user_question}
相关表结构:
{top_ddl_context}
相关业务规则:
{top_business_rules}
相似 SQL 案例:
{top_few_shot_examples}
要求:
1. 只能使用提供的表和字段。
2. 不允许使用 UPDATE、DELETE、DROP、ALTER。
3. 不允许 SELECT *。
4. 必须解释使用了哪些业务规则。
5. 如果信息不足,不要猜测,返回需要补充的信息。RAG 不是召回越多越好,而是召回越准越好。
10. 多轮对话:Query Rewrite 如何串起上下文?
用户不会每次都问完整问题。
第一轮:“查一下上个月华东区域出境票量。”
第二轮:“按渠道分组。”
第三轮:“再和去年同期对比。”
如果只看第三轮,模型不知道“什么指标、什么区域、什么时间”。所以需要 Query Rewrite,把多轮问题改写成完整问题。
查询上个月华东区域出境票量,并按渠道分组,同时与去年同期进行同比对比。上下文管理不应该无限保留聊天记录,而应该维护结构化状态:
{
"metric": "出境票量",
"region": "华东",
"time_range": "上个月",
"group_by": "渠道",
"compare": "去年同期"
}用户每次追问时,只更新相关字段。这样可以避免 Prompt 越来越长,也能减少旧条件干扰新问题。
例如用户说:“不看华东了,改成全国。”系统应该更新:
{
"region": "全国"
}而不是保留“华东”和“全国”两个条件。
11. 长会话如何控制上下文长度和 SQL 干扰?
长会话里,最大的问题是历史条件污染。
例如用户前面一直在问酒店订单,后面突然问权益核销。如果系统把所有历史都塞进去,模型可能错误地把酒店订单表和权益表 Join 起来。
比较好的做法是维护“当前分析状态”,而不是完整聊天记录。当前状态可以包含:
{
"domain": "rights",
"metric": "核销成功率",
"time_range": "近7天",
"dimensions": ["渠道"],
"filters": {
"region": "华东"
},
"last_sql": "...",
"last_result_summary": "..."
}同时要有领域切换判断。如果用户新问题明显切换业务域,系统应清空旧上下文:
检测到用户从 hotel_order 切换到 rights_verification,已重置历史 SQL 上下文,仅保留会话级用户偏好。
12. Text2SQL 准确率怎么评估?
Text2SQL 的准确率不能只用“SQL 字符串是否一致”来判断。
因为同一个问题可能有多种 SQL 写法:
COUNT(*)
COUNT(1)
COUNT(DISTINCT order_id)或者 Join 顺序不同,但结果一样。更合理的评估维度包括:
1. 可执行率
SQL 能不能成功执行。
Executable Accuracy = 可执行 SQL 数量 / 总 SQL 数量2. 结果准确率
执行结果是否与标准答案一致。这是最重要的指标。
Execution Accuracy = 结果正确 SQL 数量 / 总问题数量3. 语义准确率
SQL 是否符合业务口径。例如“GMV 不包含取消订单”,SQL 里是否过滤了取消状态。
4. 安全合规率
是否没有危险语句、越权访问、敏感字段泄露。
5. 人工审核通过率
业务人员或数据分析师抽检后是否认可。
真正上线时,建议建立测试集:
问题
标准 SQL
标准结果
涉及表
涉及字段
业务规则
难度等级每次模型、Prompt、Embedding 或规则更新后,都要跑回归测试。
13. LLM 生成 SQL 后,如何做安全和质量校验?
SQL 校验至少要分三层。
第一层:语法解析
先用 SQL Parser 解析 SQL,而不是用字符串简单判断。检查:
- 是否为 SELECT;
- 是否包含多语句;
- 是否包含危险函数;
- 是否包含 UPDATE / DELETE / DROP / ALTER;
- 是否存在 SELECT *。
第二层:权限校验
检查模型使用的表和字段是否在用户权限范围内。例如用户只能查酒店业务,就不能访问机票表或财务敏感表。
用户权限表集合 ∩ SQL 使用表集合如果 SQL 引用了未授权表,直接拦截。
第三层:质量校验
检查:
- 是否命中分区字段;
- 是否限制时间范围;
- 是否存在笛卡尔积;
- Join key 是否合理;
- 聚合字段是否正确;
- 是否误用明细表导致重复计数;
- 是否漏掉业务规则过滤条件。
例如下面这个 SQL 就有风险:
SELECT COUNT(*)
FROM rights_verification;它没有时间条件,可能扫全表。更安全的要求是:
WHERE verification_time >= xxx
AND verification_time < xxx对于大表查询,系统应该强制时间范围,或者自动加 limit / explain cost 检查。
14. 权益核销失败率高,如何收敛失败链路?
问题的后半部分开始进入分布式业务系统:权益核销、RocketMQ、Redis、MySQL、Seata AT。
这其实是在考察:AI Agent 不只是问答系统,还要接入真实业务链路。
权益核销常见链路如下:
用户发起核销
↓
订单服务校验
↓
权益服务扣减权益
↓
核销状态写入 MySQL
↓
发送 RocketMQ 消息
↓
下游服务处理
↓
缓存更新
↓
返回核销结果失败率高的原因通常有:
- 消息发送延迟;
- 消息重复投递;
- 下游消费失败;
- 状态没有同步;
- Redis 和 MySQL 不一致;
- 接口超时但实际成功;
- 补偿链路不完整;
- 缺少幂等设计。
解决思路是把关键状态事件化:
核销请求创建
核销处理中
核销成功
核销失败
核销补偿中
核销补偿成功
核销补偿失败每个状态都落库,并通过 RocketMQ 传递。
15. MySQL 是真实数据源,Redis 只是缓存
在权益系统里,Redis 通常保存临时状态或高频读取数据,但不能作为最终数据源。
MySQL 是最终事实来源,Redis 是加速层。
写入流程可以是:
先写 MySQL
↓
提交成功
↓
发送 MQ
↓
消费方更新 Redis如果 Redis 更新失败,不应该直接判定业务失败,而应该通过 MQ 重试或补偿任务修复。
常见方案:
MySQL 写成功
↓
发送权益状态变更消息
↓
Redis 消费消息更新缓存
↓
失败则重试
↓
多次失败进入补偿队列读的时候也要注意缓存异常:
先读 Redis
↓
未命中或状态异常
↓
回源 MySQL
↓
重建缓存这样可以避免 Redis 短暂不一致导致错误判断。
16. 为什么不把全部链路放进全局事务?
比较合理的解释是:
数据库更新可以用本地事务或 Seata AT,但消息链路不应该强行塞进全局事务。
因为全局事务会带来明显成本:
- 锁持有时间变长;
- 系统吞吐下降;
- 服务间耦合变强;
- 下游服务失败会拖累主链路;
- 排障复杂度提高。
对于权益核销这类场景,更常见的是:
本地事务保证核心状态落库
↓
RocketMQ 保证异步通知
↓
失败重试
↓
补偿任务兜底
↓
最终一致也就是说,主链路只保证核心状态写成功,外围状态通过消息和补偿保证最终一致。
17. RocketMQ 重复消费时,如何保证幂等?
MQ 系统一定要假设消息会重复。所以消费者必须幂等。
用订单号 + 权益 ID + 操作类型生成业务唯一键。
idempotent_key = order_id + rights_id + operation_type数据库里建唯一索引:
CREATE UNIQUE INDEX uk_rights_consume
ON rights_consume_record(order_id, rights_id, operation_type);消费消息时:
- 收到 MQ 消息;
- 生成业务唯一键;
- 插入消费记录;
- 如果插入成功,继续处理;
- 如果唯一键冲突,说明已经处理过,直接返回成功。
伪代码:
public void consume(RightsMessage message) {
String key = message.getOrderId() + "_"
+ message.getRightsId() + "_"
+ message.getOperationType();
try {
insertConsumeRecord(key, message);
} catch (DuplicateKeyException e) {
// 已消费过,直接 ACK
return;
}
boolean success = updateRightsStatusWithCondition(message);
if (!success) {
// 状态不满足,可能已经成功或已取消
return;
}
sendNextEvent(message);
}状态更新也要带条件:
UPDATE rights_record
SET status = 'CONSUMED'
WHERE order_id = #{orderId}
AND rights_id = #{rightsId}
AND status = 'INIT';这样即使重复消息来了,也只有第一次能把状态从 INIT 改成 CONSUMED。
18. 权益核销的完整防重和补偿设计
一个更完整的核销链路可以这样设计:
用户请求
↓
生成 request_id
↓
检查幂等表
↓
写核销记录 INIT
↓
扣减权益库存
↓
更新状态 SUCCESS
↓
发送 MQ
↓
下游消费
↓
更新缓存和报表失败时:
MQ 发送失败
→ 本地消息表定时扫描重发
MQ 消费失败
→ RocketMQ 重试
超过重试次数
→ 进入死信队列
死信消息
→ 人工或自动补偿任务处理
Redis 更新失败
→ 回源 MySQL + 异步刷新缓存核心原则:业务唯一键防重复,状态机防乱序,本地事务保核心,MQ 重试保投递,补偿任务保最终一致,MySQL 作为最终事实源。
19. 把 AI Agent 和业务系统结合时,真正要看的是什么?
这类面试问题不是只问你会不会 RAG,也不是只问你会不会 RocketMQ。它真正关心的是:
第一,你有没有做过工程闭环
不是“模型能回答”,而是:
能不能召回正确知识?
能不能生成正确 SQL?
能不能校验 SQL?
能不能执行失败后定位原因?
能不能根据反馈更新知识库?
能不能做回归测试?第二,你有没有风险意识
业务系统里不能把 LLM 当成完全可信组件。必须有:
- 权限控制;
- SQL 白名单;
- 字段校验;
- 查询成本控制;
- Prompt 版本管理;
- 日志追踪;
- 人工审核;
- 失败兜底。
第三,你有没有一致性思维
AI 查询系统最后可能要接业务动作,比如权益核销、订单处理、客服补偿。这时就不能只懂 Prompt,还要懂:
- MQ;
- 幂等;
- 状态机;
- 分布式事务;
- 最终一致;
- 缓存一致性;
- 补偿机制。
20. 总结:真正的 Agent 落地,是 AI 工程 + 后端工程 + 数据工程的结合
一个业务数据智能查询 Agent,表面上是 Text2SQL,底层其实融合了三类能力。
第一类是 AI 工程能力:
RAG
Embedding
Prompt Engineering
Query Rewrite
Few-shot
LLM Evaluation第二类是数据工程能力:
DDL 管理
数据字典
指标口径
SQL 校验
数据权限
查询性能优化第三类是后端工程能力:
Java / Spring Boot
Redis
MySQL
RocketMQ
分布式事务
幂等设计
最终一致性所以这类项目最有价值的地方,不是“用了大模型”,而是把大模型放进一个可靠的工程闭环里:
知识可维护
结果可评估
SQL 可校验
权限可控制
链路可追踪
失败可补偿
版本可回滚这也是企业级 Agent 和普通 Demo 最大的区别。