AI项目实战复盘:从0到1落地一个企业AI应用的完整经历
AI项目实战复盘:从0到1落地一个企业AI应用的完整经历
故事的开始
2024年9月的一个下午,我的星球成员小孙给我发来一条消息:
"老张,我们行要做一个内部AI助手,我被拉进来了,但我没做过AI项目,怎么办?"
小孙是某城市商业银行的Java工程师,工作6年,做过核心系统、营销系统,技术扎实。那个时候他学AI才半年,在业余时间做过几个RAG的Demo,还没有真正的生产经验。
银行IT部门的一位副行长看了GPT的演示,决定做一个内部知识问答助手,把几十年积累的内部规章制度、产品手册、操作指南全部放进去,让员工通过对话方式查询,减少重复咨询和培训成本。
项目被命名为"智策"。
从那天开始,到2025年3月正式上线,小孙经历了6个月的折磨与成长。他最终把这段经历完整地复盘成了一篇超过2万字的文档,分享给了我们星球。
今天,我把他的复盘提炼出来,结合我见过的其他AI项目的经验,写成这篇文章。
这是一篇写给"要在公司真正落地AI项目"的Java工程师的真实复盘。
第一部分:立项阶段——种下了什么因,就结什么果
需求分析的第一次失误
项目启动会上,副行长说了一句话:"要能回答员工的任何问题。"
小孙当时没有追问,也没有挑战这句话。他后来告诉我,这是他在这个项目里犯的第一个、也是最重要的错误。
"任何问题"是一个陷阱。它让项目的边界无限扩大,让每个人对系统的期望都不一样,最终导致上线后被抱怨"什么都回答不好"。
正确的需求分析应该做什么?
第一步:界定问题边界
把"任何问题"细化成:
- 哪些类型的问题?(规章制度查询、产品参数查询、操作流程查询...各自占多少比例?)
- 哪些内容在知识库里?(已有文档多少、多少种格式、多少是老旧过期的?)
- 哪些问题不应该由AI回答?(涉及客户信息、涉及系统操作、需要人工判断的场景)
第二步:对齐成功标准
在项目立项时,就要明确:什么叫"成功"?
小孙的教训:立项时没有定义成功标准,导致后来上线被评价为"不好用",但"好用"的定义每个人都不一样,无法举证。
成功标准的参考框架:
定量指标:
- 系统响应时间 p95 < 3秒
- 知识库覆盖的标准问题,答案准确率 > 85%
- 系统可用性 > 99.5%
定性目标:
- 员工能通过系统独立解答 X% 的规章制度类问题,无需联系人工
- IT支持工单中,知识查询类工单减少 Y%第三步:做用户访谈,而不是只听领导说
副行长的需求,代表的是管理层的期望,不一定是真实用户(基层员工)的需求。
小孙在项目中期才意识到,基层员工最常遇到的问题其实不是"规章制度查询",而是"具体操作步骤"——比如"某个业务系统里的某个功能,具体怎么操作"。这类问题需要的是操作手册,不是政策文件。
如果立项时做了用户访谈,项目的方向会完全不同。
技术选型的逻辑
关键约束:数据不能出银行内网
这是银行IT项目的铁律,小孙很快意识到,所有要调用外部API(OpenAI、通义千问云端API)的方案全部排除。
必须本地部署:
- LLM:选择Ollama + Qwen2.5-7B(银行服务器有A100 GPU,足够运行7B模型)
- 向量数据库:Milvus(支持私有化部署,性能够用)
- Embedding:BGE-large-zh(中文效果好,本地部署)
- 框架:Spring AI + LangChain4j
为什么不用更大的模型?
小孙当时评估了两个选项:Qwen2.5-7B(小模型)vs Qwen2.5-72B(大模型)。
最终选7B的原因:
- 银行现有GPU资源(2张A100)运行72B吃力,延迟会超标
- 在RAG场景下,检索质量对答案准确率的影响远大于模型大小
- 7B模型的延迟约1-2秒,72B约8-15秒,对话体验差距很大
这个决策后来被证明是对的。
资源申请的教训
小孙在项目初期低估了文档处理的工作量,没有申请足够的人力资源。
最初计划:小孙一个人(开发)+ 1个测试。
实际需要:1个开发(小孙)+ 1个数据处理(负责文档清洗和格式转换)+ 1个测试 + 业务侧兼职联络人(帮做需求澄清和验收)。
教训: AI项目最容易被低估的工作量是数据工程(文档清洗、格式转换、分块质量评估),这部分工作量有时会占整个项目的30%-40%。
第二部分:架构设计阶段——第一版方案的问题
第一版架构
小孙的第一版架构图很"标准"——按照他在网上学的RAG教程来的:
用户问题 → 向量检索 → 取前5个文档 → 拼接Prompt → 调用Qwen2.5 → 返回答案简洁,但在生产环境里行不通。
第一版的四个问题
问题一:没有意图识别,乱答一通
用户有时候不是来查知识的,而是在打招呼("你好")、在吐槽("这个系统真难用")、在问系统本身("你能帮我做什么")。
没有意图识别,系统对这些输入也去做向量检索,检索结果和用户意图完全不匹配,LLM只好"发挥想象力"——这是幻觉的温床。
问题二:没有查询改写,多轮对话质量差
用户第一次问:"XX产品的利率是多少?" 系统回答:"根据手册,XX产品年利率为X.X%。" 用户追问:"那如果提前支取呢?"
"那如果提前支取呢"——直接向量检索,会找什么?检索不到任何有用的文档,因为这个问题缺少了"XX产品提前支取"的上下文。
没有查询改写,多轮对话的质量会急剧下降。
问题三:没有结果过滤,低相关度的内容也被喂给LLM
当用户问的内容在知识库里根本没有时,向量检索会返回一些"看起来相关,实际完全不搭界"的文档(因为ANN搜索总要返回结果的)。
这些低相关度的文档被拼进Prompt,LLM根据这些错误的"知识"生成了听起来像模像样的幻觉答案。
问题四:没有引用来源,答案无法验证
银行业务对准确性要求极高,员工需要知道答案的来源是哪份文件,以便核实。第一版只返回答案文本,没有来源引用。
重构后的架构
小孙花了两周重构架构,形成了生产版本:
这个架构有几个关键改进:
- 意图识别层:减少无意义的检索,提升系统整体效率
- 查询改写:基于会话历史,把上下文依赖的问题改写成独立查询
- 双路检索 + RRF:显著提升召回率(从62%到88%)
- 相似度阈值过滤:宁可说"不知道",也不用低质量文档喂LLM
- 来源引用:每条答案都带上文档来源,方便员工核实
第三部分:开发阶段——三个最难突破的技术难题
难题一:银行文档的解析质量
银行有大量PDF文件,但这些PDF格式复杂:
- 双栏排版(文字流会乱序)
- 大量表格(表格里的数字和规则最重要,但最难提取)
- 扫描版PDF(OCR质量差)
- 图片中嵌入的文字(完全无法提取)
小孙最初用Apache Tika解析所有PDF,结果发现:
- 双栏PDF解析出来的文字顺序完全混乱,语义不连贯
- 表格变成了一列列没有对应关系的数字
- 扫描件完全无法解析
解决过程:
第一步:分类处理,不同格式用不同工具
@Service
public class DocumentParserFactory {
public String parse(File file, DocumentType type) {
return switch (type) {
case PDF_NATIVE -> parsePdfWithPdfBox(file); // 原生PDF用PDFBox
case PDF_SCANNED -> parsePdfWithOcr(file); // 扫描版用OCR(Tesseract)
case WORD -> parseWordWithApachePoi(file); // Word文档
case EXCEL -> parseExcelAsMarkdownTable(file); // Excel转Markdown表格
case HTML -> parseHtmlWithJsoup(file); // HTML文档
};
}
private String parsePdfWithPdfBox(File file) throws Exception {
try (PDDocument doc = PDDocument.load(file)) {
PDFTextStripper stripper = new PDFTextStripper();
// 关键配置:按行排序,解决双栏乱序问题
stripper.setSortByPosition(true);
stripper.setStartPage(1);
stripper.setEndPage(doc.getNumberOfPages());
return stripper.getText(doc);
}
}
private String parseExcelAsMarkdownTable(File file) throws Exception {
// 将Excel表格转换为Markdown格式,保留行列关系
try (Workbook wb = WorkbookFactory.create(file)) {
StringBuilder sb = new StringBuilder();
for (Sheet sheet : wb) {
sb.append("## ").append(sheet.getSheetName()).append("\n\n");
// 提取表头
Row headerRow = sheet.getRow(0);
if (headerRow != null) {
sb.append("| ");
headerRow.forEach(cell ->
sb.append(getCellValue(cell)).append(" | "));
sb.append("\n|");
headerRow.forEach(cell -> sb.append("---|"));
sb.append("\n");
}
// 提取数据行
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row != null) {
sb.append("| ");
row.forEach(cell ->
sb.append(getCellValue(cell)).append(" | "));
sb.append("\n");
}
}
}
return sb.toString();
}
}
}第二步:文档质量评估
解析后,用规则检测质量问题:
- 文字密度太低(可能是扫描件识别失败)
- 重复行率太高(可能是页眉页脚被重复识别)
- 特殊字符比例异常高(可能是乱码)
质量评分低于阈值的文档,标记为"需要人工处理",而不是强行入库。
这个改进极大降低了后来的幻觉率——垃圾进垃圾出,数据质量是RAG系统最重要的基础。
难题二:表格内容的检索失效问题
银行规章制度里,大量核心信息在表格里:
- 利率表(不同产品、不同期限、不同金额对应的利率)
- 手续费表
- 风险等级对照表
问题:用户问"XX产品3年期利率是多少",这个问题向量检索到的是"利率"相关的段落文字,但答案在表格里,而表格被分块后,行列关系丢失了,LLM读不懂。
解决方案:表格感知分块
把表格单独提取,不混入普通文本分块,用特殊的"表格Document"格式存储:
@Service
public class TableAwareChunker {
public List<Document> chunkWithTableAwareness(String parsedContent,
Map<String, Object> metadata) {
List<Document> chunks = new ArrayList<>();
// 识别Markdown表格区域
Pattern tablePattern = Pattern.compile(
"\\|[^\n]+\\|[^\n]*\n\\|[-|\\s]+\\|[^\n]*\n(\\|[^\n]+\\|[^\n]*\n)*",
Pattern.MULTILINE
);
Matcher matcher = tablePattern.matcher(parsedContent);
int lastEnd = 0;
while (matcher.find()) {
// 处理表格前的普通文本
if (matcher.start() > lastEnd) {
String textBefore = parsedContent.substring(lastEnd, matcher.start());
chunks.addAll(standardChunk(textBefore, metadata));
}
// 整个表格作为一个chunk(附加前置标题作为上下文)
String tableContext = extractTableContext(parsedContent, matcher.start());
String tableContent = tableContext + "\n" + matcher.group();
Map<String, Object> tableMeta = new HashMap<>(metadata);
tableMeta.put("content_type", "table");
chunks.add(new Document(tableContent, tableMeta));
lastEnd = matcher.end();
}
// 处理表格后的剩余文本
if (lastEnd < parsedContent.length()) {
chunks.addAll(standardChunk(
parsedContent.substring(lastEnd), metadata));
}
return chunks;
}
private String extractTableContext(String content, int tableStart) {
// 提取表格前最近的标题(H1/H2/H3)作为上下文
String beforeTable = content.substring(0, tableStart);
Pattern headingPattern = Pattern.compile("^#{1,3}\\s+(.+)$", Pattern.MULTILINE);
Matcher headingMatcher = headingPattern.matcher(beforeTable);
String lastHeading = "";
while (headingMatcher.find()) {
lastHeading = headingMatcher.group(0);
}
return lastHeading;
}
}效果:表格相关问题的准确率从41%提升到76%。剩下的24%主要是交叉查询(需要联合多个表格的数据),这部分在第一期没有解决,留到了后续迭代。
难题三:LLM的"发挥"问题(幻觉控制)
银行场景对幻觉的容忍度接近于零。一个员工拿着AI答案去操作,如果答案是错的,轻则流程错误,重则合规问题。
小孙最初测试时,Faithfulness评分只有0.58——意味着约40%的答案有不同程度的幻觉成分。
幻觉控制的组合拳:
拳一:Prompt强约束
你是某银行的智能助手,只能基于以下检索到的知识库内容回答员工的问题。
严格规则:
1. 只能使用以下<context>标签内的内容作为答案依据
2. 如果<context>中没有足够信息回答问题,必须说:"根据现有资料,暂无此问题的明确说明,建议联系XXX部门确认"
3. 不得基于你的训练知识添加任何<context>之外的信息
4. 回答时必须注明信息来源,格式:"(参见:[来源文档名称])"
5. 涉及利率、手续费等数字,必须原文引用,不得自行计算或推断
<context>
{retrieved_content}
</context>
员工问题:{user_question}拳二:相似度门槛过滤(宁缺毋滥)
当检索结果的最高相似度低于0.72时,直接返回"暂无相关资料",不把低质量结果喂给LLM。
拳三:答案后验证(异步)
用另一个LLM调用,对生成答案做Faithfulness校验:
- 答案中的关键信息,是否都能在检索内容中找到依据?
- 如果校验不通过,在答案末尾加上"[该答案可能包含不确定信息,请核实]"的标注
@Component
public class FaithfulnessChecker {
private final ChatClient chatClient;
/**
* 异步校验答案忠实度(不阻塞主流程,用于日志和优化)
*/
@Async
public CompletableFuture<FaithfulnessResult> checkAsync(
String question, String answer, String context) {
String checkPrompt = """
请判断以下"回答"是否完全基于"参考资料"中的内容。
评分标准:
- FAITHFUL:答案中的所有关键信息都能在参考资料中找到依据
- PARTIALLY_FAITHFUL:部分信息有依据,但有少量推断或补充
- UNFAITHFUL:答案中包含参考资料中没有的信息
参考资料:%s
回答:%s
请只返回JSON格式:{"score": "FAITHFUL/PARTIALLY_FAITHFUL/UNFAITHFUL", "reason": "原因"}
""".formatted(context, answer);
String result = chatClient.prompt()
.user(checkPrompt)
.call()
.content();
return CompletableFuture.completedFuture(parseResult(result));
}
}经过组合拳后,Faithfulness评分从0.58提升到了0.87。
第四部分:测试阶段——AI系统的测试与普通系统的不同
最大的认知颠覆
小孙说,AI系统测试是整个项目里"最颠覆他认知"的部分。
他原来的测试经验是:功能测试(输入A,预期输出B,实际输出B则通过)。
但AI系统不是这样的——同一个问题,LLM每次的输出可能都不一样(因为temperature > 0);什么叫"正确答案",本身就需要人来评判;系统的质量不是"通过/失败",而是一个连续的分布。
AI系统测试框架
小孙最终建立了一套评估框架:
层次一:评估集测试(离线评估)
建立一个包含300道问题的评估集,每道问题有人工标注的"参考答案"和"相关文档"。
用RAGAS框架,每次代码变更后自动跑评估:
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall
# 评估数据集
dataset = {
"question": [...], # 300道测试问题
"answer": [...], # 系统生成的答案
"contexts": [...], # 系统检索到的上下文
"ground_truth": [...] # 人工标注的参考答案
}
result = evaluate(
dataset=dataset,
metrics=[faithfulness, answer_relevancy, context_recall]
)
print(result)
# {'faithfulness': 0.87, 'answer_relevancy': 0.82, 'context_recall': 0.79}评估集的构成:
- 60%:从真实用户历史问题中采样
- 20%:边界case(问题超出知识库范围)
- 20%:难case(需要跨文档综合推断)
层次二:AB测试(在线评估)
上线后,对随机5%的用户开启答案评价功能(点赞/踩)。用这个数据来验证离线评估结果和线上表现是否一致。
层次三:持续监控
建立监控看板,每天自动汇报:
- Faithfulness p50/p95
- 答案相关性均值
- 用户点赞率/踩率
- "暂无相关资料"触发率(过高说明知识库覆盖不足,过低说明阈值设太低了)
AI系统测试不同于普通系统的几个特点
1. 没有100%确定的"正确答案"
同一个问题,可能有多种正确的回答方式。测试时要接受"模糊正确",用分数而不是布尔值来衡量。
2. 测试集的质量决定了评估的有效性
如果你的300道测试题都是简单直接的问题,评估结果会虚高(看起来很好),但实际用户遇到复杂问题时表现会很差。测试集要覆盖足够的难度层次。
3. 数据飘移需要持续监控
知识库会更新,用户的提问模式会变化。一个月前评估结果很好的系统,一个月后如果知识库更新了但评估没有跟上,可能在线上悄悄变差了。
4. 要测试"应该拒绝回答"的情况
很多项目只测试"能回答"的问题,但不测试"应该拒绝"的情况(知识库之外的问题)。后者测不好,会产生高置信度的幻觉答案——这比"暂无资料"更危险。
第五部分:上线阶段——灰度发布与首周危机
灰度策略
小孙没有直接全量上线,而是设计了一个三阶段灰度方案:
第一阶段(2周):仅开放给IT部门内部员工(约50人)
- 目的:发现技术层面的问题
- 成功标准:稳定运行7天,p95响应时间 < 3s
第二阶段(2周):扩大到零售业务部(约200人)
- 目的:验证核心业务场景
- 成功标准:日活用户 > 30%,满意度评分 > 3.5/5
第三阶段:全行上线(约800人)
- 触发条件:第二阶段成功指标达成第一周发生的3个问题
问题一:系统在高峰时段响应变慢(周二上午10点)
Qwen2.5-7B在同时处理超过15个并发请求时,响应时间从平均1.5s飙升到8-12s。
根因: GPU内存不够,多个请求争抢资源,导致推理队列积压。
解决: 实现请求队列,最大并发数限制为10,超出的请求排队等待(前端显示"稍等,系统处理中")。高峰期响应时间稳定在1.8-2.5s。
@Component
public class LlmConcurrencyManager {
// 最大并发推理数,根据GPU显存调整
private final Semaphore semaphore = new Semaphore(10);
// 等待超时时间
private static final int WAIT_TIMEOUT_SECONDS = 30;
public String inferWithConcurrencyControl(
ChatClient chatClient, String prompt) throws InterruptedException {
boolean acquired = semaphore.tryAcquire(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!acquired) {
throw new ServiceUnavailableException("系统繁忙,请稍后重试(等待超时)");
}
try {
return chatClient.prompt().user(prompt).call().content();
} finally {
semaphore.release();
}
}
}问题二:某类特殊问法触发了奇怪的回答(周三)
一名员工输入了:"帮我写一份关于XX产品的销售话术"。
系统检索到了产品手册,然后LLM按照指令生成了一段话术——内容是准确的,但格式和内容超出了预定的"知识查询"范围,让系统看起来"角色扮演"了。
解决: 在意图识别层加上"不支持的意图"分类,对写作类、扮演类请求直接返回"该系统仅支持知识查询功能,撰写类需求请使用其他工具"。
问题三:一份刚刚更新的制度文件,AI还在用旧版本回答(周四)
有员工反映,某个政策已经在上周更新了,但AI还在按照旧政策回答。
根因: 文档更新后,没有触发知识库重新索引;而且旧版本的文档还留在向量库里没有清除。
解决:
- 建立文档版本管理机制:每个文档有版本号,新版本入库时,自动删除旧版本的所有向量
- 增加监控:文档管理员上传新文档时,系统自动触发重索引,并发送通知
@Service
public class DocumentVersionManager {
@Transactional
public void updateDocument(String docId, File newFile, String newVersion) {
// 1. 删除旧版本的向量
String oldVersion = documentRepository.getCurrentVersion(docId);
if (oldVersion != null) {
milvusClient.delete(MilvusParam.newBuilder()
.withCollectionName("knowledge_base")
.withExpr("doc_id == '" + docId + "' and version == '" + oldVersion + "'")
.build());
log.info("Deleted old vectors for doc: {}, version: {}", docId, oldVersion);
}
// 2. 解析新版本文档
String content = documentParser.parse(newFile);
// 3. 入库新版本
List<Document> newChunks = chunker.chunk(content,
Map.of("doc_id", docId, "version", newVersion,
"updated_at", LocalDate.now().toString()));
vectorStore.add(newChunks);
// 4. 更新元数据
documentRepository.updateVersion(docId, newVersion);
log.info("Document updated: {}, new version: {}", docId, newVersion);
}
}第六部分:运营阶段——持续优化与效果数据
上线后3个月的数据
系统全量上线后的运营数据(截至小孙写复盘文档时,约3个月):
日活用户:约340人(全行800人,日活率42%)
日均查询量:820次
平均响应时间:1.6秒(p95: 2.8秒)
用户满意度:4.1/5(月度问卷,回收率68%)
Faithfulness(月均):0.86
Answer Relevancy(月均):0.83
业务价值指标:
- 员工反映的"规章制度相关咨询"减少约35%(IT支持工单统计)
- 新员工入职培训中,制度学习时间缩短约20%(HR反馈)
- 一线员工满意度反馈:80%表示"有帮助"或"非常有帮助"持续优化的方法
方法一:每周分析低满意度对话
每周从数据库里抽取用户评分低(1-2分)的对话记录,人工分析失败原因:
- 是检索没找到(召回问题)?
- 还是检索到了但答案不准(生成问题)?
- 还是知识库本身有错误(数据质量问题)?
不同原因对应不同的优化方向。
方法二:主动发现知识盲区
定期导出"暂无相关资料"的问题,整理成清单,交给业务部门确认哪些是真正应该覆盖的知识点,然后补充到知识库。
这一步帮助小孙在上线后2个月内把知识库从1200份文档扩展到了1800份,覆盖率显著提升。
方法三:A/B测试驱动Prompt优化
每次对Prompt的修改,先用评估集验证,再线上小流量(10%)验证,确认效果提升后才全量推送。
第七部分:团队管理——带没有AI经验的团队做AI项目
小孙的团队配置
项目最终参与人员:
- 小孙(AI工程师,项目负责人)
- 小刘(Java开发,负责API接口和前端集成,无AI经验)
- 老王(DBA/数据工程师,负责文档处理和Milvus运维,无AI经验)
- 小梅(测试,兼任业务联络,无AI经验)
如何让没有AI经验的队友快速上手
对小刘(Java开发):
小刘的任务是写前端API(SSE流式接口、会话管理接口)。他不需要懂LLM,只需要知道:
- 后端会推过来一个流(
Flux<String>),前端怎么处理 - 会话ID怎么生成和传递
小孙用了2个小时给小刘讲了这两个点,然后给了他一份接口文档和一个最小的Demo。小刘3天后完成了前端接口开发,没有卡点。
关键经验: 不要想着让队友"全面理解AI",只要让他们理解自己负责的那一小块就够了。知识最小化,任务边界清晰。
对老王(DBA):
老王对Milvus不熟,但他对数据库原理很熟,小孙用类比帮他建立认知:
"Milvus就是专门存向量的数据库。Collection就是表,Field就是列,向量检索就像是按照某种'相似度'排序的SELECT。你需要学的就是:怎么建Collection、怎么写CRUD、怎么查索引状态。其他的你不用管。"
老王花了3天学了Milvus的运维操作,后来成了团队里Milvus最熟的人。
对小梅(测试):
小梅是第一次测试AI系统,不知道怎么判断"答案是否正确"。
小孙给她建立了一个简单的评判框架:
评分维度(5分制):
1. 答案相关性:答案有没有回答问题?(1-5分)
2. 答案准确性:答案中的信息有没有明显错误?(1-5分)
3. 来源合理性:引用的来源文档,看起来和问题相关吗?(1-5分)
4. 拒绝合理性:如果答案是"暂无资料",这个拒绝是否合理?(1分通过/0分不通过)
整体评分:4项评分均值 >= 4.0 为通过这个框架让小梅有了评判标准,也让小孙每次看测试报告时能快速理解问题在哪里。
最关键的管理动作:定期回顾会
每周五下午的30分钟回顾会,小孙固定做两件事:
- 展示本周的评估数据(关键指标变化)
- 每人说一个"本周遇到的最难的问题"和"怎么解决的"
这个习惯让团队保持了信息同步,也让每个人都在持续学习。
第八部分:与业务方沟通——管理AI项目的期望值
最大的挑战:期望管理
小孙说,这是整个项目里他付出最多情绪成本的部分。
副行长在立项时展示了一段GPT-4的演示,那是最好状态下、精心选择的场景。员工们的期望基准,是GPT-4的演示。而他们交付的,是基于私有化部署Qwen2.5-7B的企业内部系统——在特定场景下很好,在其他场景下不如GPT-4。
这个差距,如果不提前管理,就会变成上线后的"不好用"。
期望管理的方法
方法一:早期展示,设定真实期望
不要等到快上线再展示给领导看。在第一版Demo出来后,就邀请关键利益相关者(副行长、业务部门主管)来体验,让他们亲眼看到系统的能力边界。
小孙的经验:领导们在看了第一版Demo后,主动下调了期望("哦,对于复杂问题还是答得不够好"),这反而让后来的正式上线有了"超预期"的效果。
方法二:明确"这个系统能做什么、不能做什么"
在系统上线时,附上一页简单的说明:
智策AI助手 - 使用说明
【适合的问题类型】
✓ 查询规章制度的具体条款
✓ 查询产品参数、利率、手续费
✓ 查询标准操作流程的步骤
【不适合的问题类型】
✗ 需要人工判断的复杂情境(如特殊案例处理)
✗ 客户个人信息查询
✗ 系统操作指令(如"帮我提交一笔审批")
✗ 最新通知(系统知识库有1-2天的更新延迟)
遇到系统回答"暂无资料",请联系XXX部门。这一页说明,减少了大量的误用场景,也降低了用户的挫败感。
方法三:用数据汇报,而不是感受
月度例会上,小孙给副行长汇报的不是"系统运行得挺好",而是:
"本月日活342人,较上月增长12%;用户满意度4.1分,较上月提升0.2分;Faithfulness指标0.86,保持稳定。本月新增覆盖600份文档,知识库累计1800份。下月计划:优化表格查询场景,预计提升该类问题准确率15-20%。"
数字说话,领导信服,也让团队的工作看得见价值。
第九部分:项目经验总结
如果重来一次,哪些决策会不同
决定一:立项时一定要做用户访谈
不能只听领导的需求,要亲自访谈10-20个潜在用户,了解他们真实的工作场景和痛点。这个投资回报是最高的,能避免后期大量的方向性返工。
决定二:评估集要在开发启动时同步建立
小孙的评估集是在系统第一版完成后才建立的。正确的做法是:在需求确定后,立刻开始收集真实问题、标注答案,与开发并行推进。这样开发一完成就能立刻评估,而不是再花2-3周建评估集。
决定三:文档质量要先投入人力
不要假设企业的文档都是整洁可用的。一开始就安排专门的人力做文档清洗(去掉过期文档、修正格式问题、处理表格),而不是等到发现问题再补救。
决定四:第一期范围要更小
小孙覆盖了全行的所有规章制度文档(1200份),导致数据质量参差不齐,早期问题很多。
正确做法:第一期只覆盖1-2个最核心的知识域(比如只做"零售产品手册"),做精、做深,上线后效果好,再扩展到其他知识域。
决定五:提前建立运营流程
知识库维护(谁来更新、多久更新一次)、问题反馈处理(谁来看评分低的对话)——这些运营机制,应该在上线前就设计好,而不是上线后发现没人管。
这个项目最有价值的收获
小孙在复盘文档的最后写了这样一段话:
"6个月之前,我觉得AI项目很神秘,不知道从哪里下手。6个月之后,我意识到它本质上还是一个工程项目——有需求分析、有架构设计、有测试、有运营。AI带来的是新的工具和新的挑战,但解决问题的思路和我做了6年的Java项目没有本质区别。
最大的不同是:AI系统的质量有更多模糊性,不是非0即1的,需要建立一套评估体系来持续量化和监控。这是我以前在Java开发里没有遇到过的。
但正是因为这个挑战,让我觉得这件事更有意思。"
完整架构代码:生产级RAG核心服务
以下是"智策"项目的核心服务代码,经过脱敏后分享:
@Service
@Slf4j
public class ZhiceRagService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final ElasticsearchClient esClient;
private final StringRedisTemplate redisTemplate;
private final FaithfulnessChecker faithfulnessChecker;
private final UsageMetricsRecorder metricsRecorder;
private static final String KNOWLEDGE_COLLECTION = "bank_knowledge";
private static final float SIMILARITY_THRESHOLD = 0.72f;
private static final int TOP_K = 8;
/**
* 主查询接口:意图识别 → 查询改写 → 混合检索 → 生成答案
*/
public RagResponse query(String question, String sessionId, String userId) {
long startTime = System.currentTimeMillis();
try {
// 1. 意图识别
QueryIntent intent = classifyIntent(question);
if (intent == QueryIntent.GREETING || intent == QueryIntent.UNSUPPORTED) {
return handleNonKnowledgeQuery(intent, question);
}
// 2. 加载会话历史
List<ConversationMessage> history = loadHistory(sessionId);
// 3. 查询改写
String refinedQuery = refineQuery(question, history);
log.debug("Query refined: '{}' -> '{}'", question, refinedQuery);
// 4. 混合检索
List<RetrievedDocument> docs = hybridSearch(refinedQuery, intent);
// 5. 无结果处理
if (docs.isEmpty()) {
metricsRecorder.recordNoResult(question, userId);
return RagResponse.noResult(
"根据现有知识库,暂无关于此问题的明确资料。" +
"如有业务需要,请联系相关业务部门。");
}
// 6. 构建Prompt并生成答案
String answer = generateAnswer(question, docs, history);
// 7. 更新会话历史
updateHistory(sessionId, question, answer);
// 8. 异步校验Faithfulness
String contextForCheck = docs.stream()
.map(RetrievedDocument::getContent)
.collect(Collectors.joining("\n---\n"));
faithfulnessChecker.checkAsync(question, answer, contextForCheck)
.thenAccept(result -> metricsRecorder.recordFaithfulness(
sessionId, result));
// 9. 记录指标
long latency = System.currentTimeMillis() - startTime;
metricsRecorder.recordQuery(userId, latency, docs.size());
return RagResponse.success(answer, extractSources(docs));
} catch (Exception e) {
log.error("RAG query failed for session: {}", sessionId, e);
metricsRecorder.recordError(userId, e.getClass().getSimpleName());
return RagResponse.error("系统暂时遇到问题,请稍后重试。如紧急需要,请联系IT支持。");
}
}
private QueryIntent classifyIntent(String question) {
String intentPrompt = """
分析以下用户输入的意图,返回以下分类之一:
- KNOWLEDGE_QUERY:用户在查询制度、产品、流程等知识
- OPERATION_GUIDE:用户在询问系统操作步骤
- GREETING:问候语、无实质内容
- UNSUPPORTED:写作请求、数学计算、角色扮演等不支持的类型
只返回分类名称,不要解释。
用户输入:%s
""".formatted(question);
String result = chatClient.prompt()
.user(intentPrompt)
.call()
.content()
.trim();
try {
return QueryIntent.valueOf(result);
} catch (IllegalArgumentException e) {
return QueryIntent.KNOWLEDGE_QUERY; // 默认当作知识查询
}
}
private String refineQuery(String question, List<ConversationMessage> history) {
if (history.isEmpty()) {
return question;
}
// 判断是否需要改写(代词、上文引用等)
if (!needsRefinement(question)) {
return question;
}
String historyStr = history.stream()
.map(m -> m.role() + ": " + m.content())
.collect(Collectors.joining("\n"));
String refinePrompt = """
根据对话历史,将当前问题改写成独立的、不依赖上下文的搜索查询。
保持问题的核心意图,只用一句话,不要解释。
对话历史:
%s
当前问题:%s
改写后的查询:
""".formatted(historyStr, question);
return chatClient.prompt()
.user(refinePrompt)
.call()
.content()
.trim();
}
private boolean needsRefinement(String question) {
// 简单规则:包含代词或上文引用词时需要改写
String[] refinementTriggers = {"这个", "那个", "上面", "之前", "刚才", "它", "该"};
for (String trigger : refinementTriggers) {
if (question.contains(trigger)) return true;
}
return false;
}
private List<RetrievedDocument> hybridSearch(String query, QueryIntent intent) {
// 并行执行向量检索和BM25检索
CompletableFuture<List<RetrievedDocument>> vectorFuture =
CompletableFuture.supplyAsync(() -> vectorSearch(query));
CompletableFuture<List<RetrievedDocument>> bm25Future =
CompletableFuture.supplyAsync(() -> bm25Search(query));
CompletableFuture.allOf(vectorFuture, bm25Future).join();
List<RetrievedDocument> vectorDocs = vectorFuture.join();
List<RetrievedDocument> bm25Docs = bm25Future.join();
// RRF融合
List<RetrievedDocument> merged = rrfMerge(vectorDocs, bm25Docs);
// 相似度阈值过滤
return merged.stream()
.filter(doc -> doc.getScore() >= SIMILARITY_THRESHOLD)
.limit(5) // 最多5个chunk送给LLM
.collect(Collectors.toList());
}
private String generateAnswer(String question,
List<RetrievedDocument> docs,
List<ConversationMessage> history) {
String context = docs.stream()
.map(doc -> String.format("【来源:%s,章节:%s】\n%s",
doc.getSource(), doc.getSection(), doc.getContent()))
.collect(Collectors.joining("\n\n---\n\n"));
String systemPrompt = """
你是某银行的智能知识助手,专门帮助员工查询内部制度和产品信息。
核心规则:
1. 只能基于以下<context>中的内容回答,不得添加任何额外信息
2. 回答后必须注明来源,格式:(参见:来源文档名)
3. 如果<context>中没有足够信息,必须说明"根据现有资料暂无此问题的明确说明"
4. 涉及金额、利率、日期等关键数字,必须原文引用
5. 语言简洁、专业,适合银行内部使用
<context>
%s
</context>
""".formatted(context);
// 构建包含历史的消息列表
List<Message> messages = new ArrayList<>();
// 只取最近4轮对话(8条消息)
history.stream()
.skip(Math.max(0, history.size() - 8))
.forEach(m -> messages.add(
m.role().equals("user")
? new UserMessage(m.content())
: new AssistantMessage(m.content())
));
return chatClient.prompt()
.system(systemPrompt)
.messages(messages)
.user(question)
.call()
.content();
}
private List<ConversationMessage> loadHistory(String sessionId) {
String key = "session:bank:" + sessionId;
List<String> raw = redisTemplate.opsForList().range(key, 0, -1);
if (raw == null) return new ArrayList<>();
return raw.stream()
.map(this::parseMessage)
.collect(Collectors.toList());
}
private void updateHistory(String sessionId, String question, String answer) {
String key = "session:bank:" + sessionId;
redisTemplate.opsForList().rightPushAll(key,
"user:" + question, "assistant:" + answer);
redisTemplate.expire(key, Duration.ofHours(8)); // 工作日内有效
redisTemplate.opsForList().trim(key, -20, -1); // 保留最近10轮
}
}FAQ
Q1:银行这么保守,内部推AI项目怎么获得支持?
A:从解决真实痛点入手,找到一个数字可以说话的场景。小孙的切入点是"减少重复咨询工单"——这个痛点IT部门和业务部门都感同身受。有了领导认可,后续推进就顺多了。
Q2:没有GPU服务器,能做本地化部署吗?
A:可以。7B以下的模型(如Qwen2.5-7B),可以在有M系列芯片的Mac上运行(Ollama支持),生产环境也可以租用GPU服务器(按小时计费)。如果公司有数据合规要求但又买不起GPU,可以考虑阿里云/腾讯云的私有化部署方案(大模型运行在云上但在你的VPC隔离环境里)。
Q3:知识库有多少文档才够用?
A:质量比数量重要。1000份高质量、经过清洗的文档,远好于5000份混乱的原始文档。从核心场景的核心文档开始,先保证这部分的质量。
Q4:如何说服领导接受"AI答错了"是可以接受的?
A:类比人工客服。人工客服也会回答错误,但我们有培训、有监督机制来持续改进。AI系统同样——你不需要追求100%准确,你需要建立一套发现错误、修复错误的机制。如果AI的错误率低于人工的错误率,而且成本更低,这就是成功。
Q5:项目上线后,我没有时间维护怎么办?
A:在立项时就要确定维护责任人和流程,不能让开发工程师一个人承担所有运营工作。标准的维护分工:文档更新由业务部门负责,技术维护由IT维护,数据分析每月一次(1-2天工作量)。
结语
小孙在复盘的最后,算了一笔账:
项目上线3个月,减少IT支持工单约800张,按每张工单平均处理时间30分钟、IT人力成本100元/小时来算,节省了约4万元的人力成本。加上员工效率提升的间接价值,项目投入产出比是正的。
但他说,这不是他最在意的数字。
他最在意的是这段话:"有几个年轻的员工跟我说,现在遇到不懂的制度问题,不用再去翻那些密密麻麻的PDF了,直接问系统,一秒钟就知道了。他们说,感觉工作突然变轻松了一点点。"
一点点,也是价值。
这就是AI落地的意义。
