第六部分:跨界与新局

0 阅读17分钟

第六部分:跨界与新局

第二十章 Excel模板驱动报表——业务人员改格式,零开发

本章核心:Excel当模板,业务人员改格式零开发,一个Servlet通吃所有报表。

政务系统的报表,两个特点:维度经常变,而且很急。

业务主管部门不定期提要求——社保基金审计按地区统计,医保专项整治按病种分类,新农保核查按年度趋势。每次维度都不一样,不是简单加减列,是全新的组合。更麻烦的是急——省里突然要一组数据支持决策,明天开会要用,不是"下周给你",是"今天下班前"。这种需求一来,开发人员别的活全停。

格式也是。改起来没完没了:

周一上午,业务处的人来了:"报表表头加一列'缴费地'。"

我放下手上的活,找到报表对应的代码,加了字段,调了列宽,本地跑通,提测试,等部署。周三上线。

周三下午,电话又来了:"列宽调一下,'缴费地'那列太窄了,身份证号显示不全。"

再改代码,调列宽,提测试。周四上线。

周四傍晚:"表头字体能不能大一号?领导说字太小了。"

改代码,调字体大小,提测试。周五上午上线。

周五下午:"上个月那张报表的格式也要改。对了,之前改的那个表头……还是原来的好。"

我盯着屏幕上七八个报表相关的代码文件,每个都改了两三遍。这一周,我什么正事都没干,全在调报表格式。

问题出在哪? 报表格式是业务需求,但改格式要走开发流程——改代码→测试→上线,一个循环至少两天。政务系统的报表有个特点:格式变动特别频繁。今天加列,明天调宽,后天换字体,大后天又要合并单元格。开发人员变成了业务部门的"格式排版工具"。

用专业的报表引擎如JasperReports?太重了,学习成本高,政务系统的报表格式变化多端,用模板引擎画模板有时候比写代码还麻烦。

能不能让业务人员自己改格式?他们最熟悉的工具就是Excel。

20.1 思考过程

报表的本质是什么? 格式+数据。格式是表头、列宽、字体、边框、合并单元格……数据是数据库查出来的字段值。

Excel能不能当模板用? 当然可以。Excel文件本身就是一个格式定义——表头写好了、列宽调好了、边框画好了。如果能在运行时把数据填进对应的单元格,不就等于报表了吗?

怎么做数据填充? jxl库可以读写Excel。定义一套占位符语法——在Excel单元格里写特殊开头的字符串,框架扫描到就替换成实际数据。

20.2 怎么做的

占位符语法:

占位符含义示例
~数据集名.字段名多行数据(循环展开)~users.name
$数据集名.字段名单行数据(取第一行)$header.title
&变量名系统变量&userName=当前用户名

是最关键的——它会根据数据集的行数动态插入行。这是模板引擎里最复杂的一步:你不能直接在模板行上替换文本就完事——假设模板第5行写的是users.name,数据集有20行用户,那这一行要"展开"成20行,而且后面的行号要跟着后移,合并单元格的范围也要跟着调整。

实现方式是用一个used[][]二维数组跟踪每个单元格是否被占用。扫描模板时遇到~占位符,先查数据集行数N,在当前位置插入N-1个空行(因为模板本身有一行),然后逐行填充数据,每填充一个格子就在used[][]中标记为已占用。后续扫描遇到已占用的格子就跳过。这样多行数据和固定表头可以自然混合排版——表头行的合并单元格不受影响,数据行正确展开。

$简单得多——取数据集第一行指定字段的值,直接替换字符串。适合报表标题、单位名称、日期等单值信息。

&变量从ThreadLocal上下文(第七章讲的AppContextContainer)中取当前用户名、当前部门、当前日期等系统信息。

处理流程:

前端POST JSON数据 + 模板文件名
→ 后端用jxl读取Excel模板
→ 逐单元格扫描占位符并填充数据
→ 用iText转成PDF
→ 输出到浏览器在线预览

jxl扫描的核心逻辑:打开Excel文件→遍历所有Sheet→遍历所有行→遍历每个单元格→检查内容是否以~、$、&开头→匹配到就做数据替换。多行数据(~)的展开在替换时同步完成。

还有一个实用的细节:^前缀实现套打。 模板中单元格内容以^开头的数据,渲染时不输出。这个功能专门为政务套打场景设计——预制表格已经印好了表头和边框,打印时只需要填充数据,不需要重复输出表头文字。比如社保待遇核定表,纸张是统一印制的,表头、边框、编号栏都已经印好了。在Excel模板里把不需要打印的单元格内容加上^前缀,生成的PDF就自动跳过这些内容,完美套印到预制表格上。这个功能是现场实施时被逼出来的——实施人员拿了一摞预制表格说"能不能直接打在这上面?"

一个Servlet通吃所有报表: doPrintView接收两个参数——file(模板文件名)和dc(DataCenter数据包)。不同业务只需要传不同的模板和数据。前端统一调用这个Servlet,不需要为每个报表写独立的导出接口。新增一个报表?做个Excel模板上传到服务器,前端加一个按钮调用doPrintView?file=新报表.xls,零后端开发。

Excel 转PDF的细节——iText逐格还原

doPrintView的核心流程不只是"填充数据"——填充完还要转PDF。这一步才是最耗精力的。

jxl填充完数据后,框架用iText库逐单元格读取Excel内容和格式,然后精确还原到PDF中。为什么不用现成的Excel转PDF工具?因为现成工具处理不好合并单元格和中文。iText虽然需要逐格手写还原逻辑,但每一行每一列都完全可控。

还原过程分两个阶段。第一阶段:逐行逐列读取Excel的每个单元格,提取值、字体大小、加粗、对齐方式、边框信息。第二阶段:用iText的PdfPTable逐格写入PDF——STSongStd-Light + UniGB-UCS2-H处理中文字体,合并单元格用cell.setRowspan() / cell.setColspan()还原,边框四个方向独立判断,逐个disableBorderSide()处理。

纸张方向由page参数控制——v纵向(默认),h横向(A4旋转)。政务报表的表头经常很宽,横向是刚需。

水印——政务合规的防伪手段

doPrintView还支持水印功能。政务报表打印后要防复印、防扫描再利用,水印是标准合规要求。实现方式:iText的PdfStamper在PDF每一页的底层叠加水印图片,scalePercent(35f)缩放到35%大小,getUnderContent()放在正文下面不影响阅读。水印图片通过markfile参数指定——业务部门提供本单位的水印图片,框架叠加到每一页上。

这个功能不是一开始就有的,是实施时甲方提出的合规要求。"你们的报表能不能加水印?" 有了doPrintView这个统一入口,加水印只需要在输出PDF之前多一步PdfStamper处理。如果报表是分散在各个业务模块里各自导出的,这个功能得改几十个地方。

getPath()的三重兼容

doPrintView还有一个细节——getPath()方法用三种方式获取Web应用根路径:先试getServletContext().getRealPath()(Tomcat),不行再试getClass().getResource()(备用),还不行再试getServletContext().getResource()(WebLogic/国产中间件)。三种方式都试一遍,总有一个能拿到。

这就是第一章讲过的现实:同一个war包要部署到Tomcat、WebLogic、东方通、金蝶天燕……不同中间件获取路径的方式不同。如果只用getRealPath(),在国产中间件上可能返回null。三种方式降级处理,就是政务系统适配多运行环境的缩影。

20.3 决策背后的WHY

为什么不用专业的报表引擎? 因为重。我们用过润乾报表——设计器复杂,开发人员要学,业务人员更学不会。每次改个格式都要找开发,和直接写代码没什么区别。JasperReports也一样,学习成本不低,政务报表格式变化多端,用报表引擎画模板有时候比写代码还麻烦。Excel是所有业务人员都会用的工具,直接用它当模板最自然,调格式也方便,设计模板时直接出来和用户确认。

为什么输出PDF而不是Excel? 因为PDF不会被篡改。政务报表经常需要打印、归档,PDF是最安全的格式。而且jxl生成的Excel直接转PDF,保留格式。

一句话:用业务人员最熟悉的工具——Excel——作为报表的格式定义,把开发人员从报表格式变更中解放出来。格式变了?和业务人员坐下来一起调Excel模板,调完确认,上传覆盖,零开发。

后来,那周折磨我的报表改格式的事再也没找过开发人员。业务人员自己调Excel,调到满意了,传到服务器上,报表格式就变了。周一到周五,零代码改动。

不盲目依赖专业报表引擎,而是用所有人都会的Excel解决问题——务实主义认为"最合适的工具"往往不一定是"最专业的工具"。

这个决策还有一个延伸收益:报表的维护权从开发团队转移到了业务部门。 以前改一个报表格式要提需求→排期→开发→测试→上线,周期至少一周。现在业务人员自己调Excel模板,调完确认,上传覆盖,5分钟搞定。开发团队从"报表格式变更"这个低价值的重复劳动中彻底解放出来,把精力放在真正需要技术能力的架构决策和功能开发上。

在政务项目里,"解放开发团队的时间"不是小事。一个项目组通常就两三个后端,如果每周都被报表格式变更占据一半时间,真正重要的架构决策和性能优化就没人做了。Excel模板驱动报表,本质上是用"工具民主化"来解决"人力瓶颈"——让不需要高级技术能力的人能完成的工作,不占用高级技术能力的人的时间。

本章小结

• 润乾报表学习成本高,不如Excel直观——业务人员自己调模板,开发人员从格式变更中解放。

• 三种占位符(~多行、$单行、&变量)+ ^前缀实现套打,一个Servlet通吃所有报表。

• 格式变了?上传Excel模板覆盖,零开发,周一到周五不用改代码。

决策洞察:把不需要高级技术能力的工作从开发团队手里拿走,是解决"人力瓶颈"最务实的方式。

第二十一章 智能客服RAG实战——从传统系统到AI的务实落地

本章核心:RAG锚定知识库防幻觉,Embedding本地部署保安全,Flask手写300行替代LangChain。

"你的养老金可以这样领:带上身份证去社保局窗口,填写申请表,工作人员审核通过后,次月起按月发放到你的银行账户。"

听起来挺靠谱的回答,对吗?

问题是——这段话是大模型编的。实际的养老金领取流程根本不是这样的。不同地区、不同险种、不同年龄,流程完全不同。但大模型不知道,它"自信地"用最通用的口吻编了一个看似合理的答案。

如果在政务热线里,群众按这个回答去办,白跑一趟社保局是轻的——真正麻烦的是,这种错误会严重损害政务系统的公信力。

这就是大模型最危险的地方:它不知道自己不知道。

在社保、医保、就业等公共服务领域,每天都有大量群众拨打热线咨询相似问题。"缴费年限不够怎么办""报销需要什么材料""灵活就业怎么参保"……传统人工客服成本高、效率低。基于关键词匹配的机器人又难以理解用户的真实意图——用户说"我年纪大了还能交社保吗",关键词匹配可能找不到"超龄参保"这条知识。

2024年,大模型技术成熟了。但直接把大模型放在群众面前?上面那个例子就是答案——不行。

21.1 怎么让AI"说真话"

RAG(检索增强生成)给出了一个思路:先从知识库中检索相关的专业问答,再让大模型基于检索结果生成回答。不是让大模型"自己想",而是让它"照着标准答案说"。

政务场景下的RAG落地,约束很明确:Embedding必须本地部署——政务咨询数据包含参保人信息、医保报销细节,不能出内网。本地方案:Ollama部署bge-m3模型。向量数据库用Milvus——开源、轻量、专注向量检索。大模型用DeepSeek API(推理能力强、性价比高)。不用LangChain或LlamaIndex——依赖链太长,政务内网离线部署困难。用Flask手写300行核心服务,和手写IOC的逻辑一样:宁可多写几行代码,也不引入不确定的依赖。

21.2 怎么做的

知识库构建: 把政务问答数据(社保、医保、就业等常见Q&A)用bge-m3向量化后存入Milvus。bge-m3通过Ollama本地部署——一行命令ollama run bge-m3就能跑起来,不需要GPU,普通服务器即可。

Milvus的集合定义了四个字段——uid(主键)、Question(问题文本)、Answer(答案文本)、Vector(1024维浮点向量,bge-m3的输出维度)。索引用IVF_FLAT+COSINE相似度,nlist=1024。这是在检索精度和速度之间取的平衡——IVF_FLAT比FLAT快(聚类剪枝),比IVF_PQ准(不压缩向量)。政务知识库量级通常在几万条,这个配置足够。

导入时做去重——先检索是否存在高度相似的问题(COSINE相似度>0.87),避免重复入库。这个阈值不是理论算出来的,是试出来的。原始数据有20万条问答记录,不洗数据根本没法用——大量重复问法、同一问题的多种表述、甚至一模一样的数据插了好几遍。最后洗成1万多条干净数据,0.87是在这个过程中反复调试得出的分界点:0.85以下会把同一问题的不同表述当成两条独立知识(误区分),0.90以上会把语义相近但确实不同的两条知识合并(误合并)。

向量化时的一个细节:问题(Question)和答案(Answer)分开存储,但只对Question做向量索引。检索时用用户提问的向量去匹配Question,返回对应的Answer作为上下文。为什么不把Q+A一起向量化?因为用户的提问更像Question而不是Answer,分开索引检索精度更高。

知识库去重的实现

def import_knowledge(client, question, answer, uid):
vector = vectorize_text(question)
search_params = {"metric_type": "COSINE", "params": {"radius": 0.87}}
res = client.search(collection_name="SI_knowledge",
data=[vector], limit=10, output_fields=["uid", "Question"],
search_params=search_params)
if len(res[0]) > 0:
print(f"已存在相似问题: {res[0][0]['entity']['Question']}")
return
data = {"uid": uid, "Question": question, "Answer": answer, "Vector": vector}
client.insert(collection_name="SI_knowledge", data=data)

这段代码的radius=0.87就是去重的核心——Milvus的search方法只返回相似度超过0.87的结果。如果返回了结果,说明知识库中已经有语义高度相似的问题,不再重复插入。注意这里用的是检索时的radius(0.87),比正常问答检索的阈值(0.5)严格得多——去重宁可漏删也不误删。这个0.87是从20万条脏数据洗成1万多条的过程中试出来的,不是拍脑袋定的。

核心问答接口: 用Flask实现,流式返回:

@app.route('/getAnswerStream', methods=['GET'])
def get_answer_stream():
question = request.args.get('q')

    # 1. bge-m3向量化
vector = vectorize_text(question)

    # 2. Milvus语义检索Top-20
res = client.search(collection_name="SI_knowledge",
data=[vector], limit=20, output_fields=["Question", "Answer"])

    # 3. 检索结果作为上下文注入DeepSeek
for item in res[0]:
messages.append({"role": "system",
"content": f"问:{item['entity']['Question']}?"
f"答:{item['entity']['Answer']}"})

    # 4. 流式返回
for chunk in client.chat.completions.create(
model="deepseek-chat", messages=messages, stream=True):
yield chunk.choices[0].delta.content

为什么检索Top-20而不是Top-5?因为政务咨询的问题往往需要综合多个知识点。比如"我交了15年社保,现在55岁能领养老金吗",这个问题涉及缴费年限、法定退休年龄、领取条件三个知识点。Top-5可能只覆盖缴费年限和退休年龄,漏了领取条件。Top-20基本能覆盖所有相关知识,而且多余的上下文不会影响大模型——DeepSeek会自动筛选最相关的部分。

流式输出的实现

同步返回对用户体验不好——用户要等5-10秒才能看到完整回答。SSE(Server-Sent Events)流式输出让用户边看边等,体感好得多。

流式的关键在于DeepSeek API的stream=True参数。请求发出后,服务器逐token返回数据,每条数据是data: {...}格式的SSE消息。Flask用Response(stream_with_context(generate()))把生成器包装成流式响应:

def generate():
for line in response.iter_lines():
if line:
text = safe_decode(line).replace("data: ", "")
try:
chunk = json.loads(text)
content = chunk["choices"][0]["delta"]["content"]
yield content
except Exception:
pass
return Response(stream_with_context(generate()), mimetype='text/plain')

前端用ReadableStream读取,每收到一块文本就追加到页面。回答完成后,用分隔符把答案和推荐问题分开——答案用marked.js渲染Markdown,推荐问题渲染成可点击的链接,用户点击后自动发送新问题。

还有一个细节:DeepSeek-R1模型有时会在输出开头加上思考标记(>\u25b8),框架用parse_answer_and_recommend()检测并跳过这些标记,只提取真正的回答内容。这种"处理模型输出格式"的代码不复杂,但必须要有——否则前端会显示一堆乱码。

Prompt工程的关键约束:

说实话,一开始我不知道"Prompt工程"这个术语。我就是看到AI乱说,而且返回数据拼得乱七八糟的。所以就给它定了规矩——用分割符把上下文、问题、输出格式严格隔开。

• "只能使用提供的上下文进行逻辑推理"——防止大模型幻觉。这行是最重要的,它把大模型"锚定"在知识库上,不给它自由发挥的空间。

• "不要提示根据提供的上下文"——让回答更自然。用户不需要知道"根据XX规定",他们只需要知道答案。

• "推荐相关的3个问题"——引导用户继续咨询。政务咨询的特点是一件事往往牵出多件事——问完养老金领取条件,可能还想问"怎么计算金额""在哪领"。

• "如果上下文中没有相关信息,请明确告知'我暂时无法回答这个问题,建议您拨打12333咨询'"——兜底策略。宁可说"不知道",也不能编。

前端支持语音交互: 集成讯飞语音识别(iat)实现语音输入,讯飞TTS实现语音播报,形成完整的语音交互闭环。为什么用讯飞而不用浏览器原生Web Speech API?因为政务大厅的电脑可能还在跑IE兼容模式,原生API兼容性不够。讯飞是WebSocket方案,兼容性更好。

RAG不是为了让系统更"智能",而是为了让AI不敢乱说——每一句话都有知识库里的标准答案做依据。在政务场景下,这种"锚定"比"智能"重要一万倍。

本章小结

• 大模型会"自信地编造",直接面向群众不可接受。RAG让回答锚定在知识库上,降低幻觉风险。

• Embedding本地部署(bge-m3+Ollama),数据不出内网,政务安全底线不能突破。

• 新技术落地的顺序:先解决安全问题,再释放技术红利。

决策洞察:RAG不是让AI更"智能",是让AI不敢乱说。在政务场景下,"锚定"比"智能"重要一万倍。新技术落地也一样——先锚定安全底线,再释放能力。