做问数系统的人前两篇把自然语言转SQL、查询执行器折腾得差不多了往往会发现一个尴尬局面用户问上个月华东区销售怎么样系统答得挺利索表格也出来了。但到了周会月报业务负责人要的不是一句回答而是一份能直接拿去讲的结构化数据报告——有总览结论、有数据证据、有图表、有归因分析。这时候你就得把问数系统往前再推一步让它从会回答问题升级成会写报告。这篇就是这个系列的第三篇重点讲清楚一件事在已有的自然语言查询能力之上如何让系统自动生成一份像样的数据报告。适合已经跑通NL2SQL基础能力、准备把产品能力往分析自动化方向上做的团队或开发者。我会直接给架构思路、核心代码逻辑、踩坑记录和实测效果不绕弯子。1. 为什么问数系统要会写报告而不只是答问题1.1 问答与报告的本质差异很多团队的误区是把报告当成大号的问答以为只要把用户的问题拆成多个子查询、把结果拼在一起再让大模型写一段话就是报告了。这个思路做出来的东西大概率是一堆数据的罗列没有叙事结构也没有结论优先级。问答和报告在本质上是两种东西。问答是交互式的用户问一个具体问题系统给一个具体答案链路短、时效强答完即走。报告是沉淀式的它要有结论、有依据、有顺序读者可能是没参与这次查询的人需要靠报告本身就能理解业务状况。打个比方电话咨询是你发烧了建议多喝水书面诊断书是体温38.5度、白细胞指标X、结合近期症状判断为轻度感染建议用药并复诊。后者需要把证据和结论结构化地组织起来。这个差异决定了实现方式完全不同。问答系统可以靠一次查询一次生成搞定报告系统则必须有一套骨架识别 → 分块取数 → 计算结论 → 编排文案 → 渲染图表 → 一致性校验的流水线。1.2 真实场景一份周报是怎么被消费的我拿业务周报举例。某零售企业的业务负责人每周一上午要开经营例会。他让问数系统生成一份上周销售复盘报告。如果只是问答他得一句一句问上周总销售额多少哪个区域下降最多哪个品类拖累最大有哪些异常门店问完还要自己把这些信息拼成PPT。有了报告生成能力系统直接产出总览段上周总销售额、环比、同比一句话概括本期整体表现。地域拆解哪些省份上升、哪些省份下降各自贡献了多少增长/下滑。品类拆解重点品类变化情况找出主要拖累项。异常提示单日或单店显著偏离正常范围的记录。图表配文每个段落配一张对应图表图上数据和正文结论完全一致。他用这份报告直接去开会需要追问细节时才再进入问答模式。这个先报告后问答的使用方式才是问数系统在企业里真正高频、高价值的使用路径。1.3 什么场景特别需要这种能力周期性看数的场景基本都适合周报、月报、季度复盘、活动效果复盘、库存盘点、渠道分析。判断标准很简单——如果你发现用户反复问同样结构的问题每次都把答案手动整理成文档那他就需要报告生成能力。这类需求的共性特征是结构稳定、结论可预期、关注异常而非关注整体正好适合用模板骨架自动取数来实现。2. 整体链路一句话报告请求如何变成结构化文档2.1 骨架先行的设计不要把报告当大号问答处理我一开始也犯过这个错让大模型直接把报告请求拆成几个SQL查完再让模型写文章。结果有两个大问题一是上下文超长模型顾头不顾尾二是模型在拼接数据时自由度太高经常把两个不相关的指标扯到一起数字对不上。后来我改成骨架先行的做法。系统先从用户的报告请求里识别出报告类型每种类型对应一套固定的分析动作序列。常见报告类型我分了四类报告类型主线问题典型分析动作适用场景趋势分析型指标怎么变同环比、波动拐点、连续涨跌周报月报对比拆分型差异在哪维度排名、占比、差距计算区域/渠道对比进度追踪型目标完成多少完成率、缺口、倒推预测销售目标管理异动排查型为什么异常异常点检测、相关性、归因活动效果复盘骨架识别出来之后它包含四个要素指标集、时间范围、拆分维度、分析动作序列。比如生成一份上月华东区销售复盘报告解析出来大概是指标集销售额、订单量时间范围上月拆分维度省份、品类、门店分析动作同环比、TopN、占比、异常检测。这个解析结果就是后面所有查询和计算的施工图纸。2.2 查询编排一次报告请求会触发多少次数据回源骨架确定以后系统要做的第一件事不是生成文案而是先把所有数据取齐。我用一个查询编排器统一管理取数任务。以上面那份华东区复盘报告为例实际会触发以下查询华东区上月销售额汇总带上环比和同比数值。按省份分组的上月销售额用于排名和占比。按品类分组的上月销售额用于发现品类变化。按门店分组的上月销售额用于找出异常下滑门店。单日销售额序列用于异常点检测。这五类查询可以并行执行。我在查询编排器里用异步任务并发拉取而不是串行等待。报告生成本身对延迟要求比问答低一些4到6秒是用户可以接受的但串行跑5个慢查询很容易冲到15秒以上体验就差了。所以并发的收益非常明显。所有查询结果先落到一个报告数据快照的内存结构里后续的分析计算和文案生成都只读这个快照不再回源数据库。这样做还有一个额外的好处保证报告里所有数字来自同一个时间点避免查询间隙数据库数据变动导致的结论不一致。dataclass class ReportDataSnapshot: report_id: str created_at: str tables: dict[str, pd.DataFrame] # 查询结果宽表 metric_defs: dict[str, MetricMeta] # 指标定义 dimension_defs: dict[str, list[str]] # 维度值列表 source_queries: dict[str, str] # 查询ID - SQL,用于追溯这个快照是整份报告的唯一事实来源后面所有模块都围绕它工作。谁要改数字必须回到查询层去改SQL而不是在文案层改字段。2.3 三层生成逻辑计算、渲染、润色分离整体生成链路我拆成三层第一层是分析计算层跑确定性的计算函数输出结论片段这一层不允许模型参与保证准确。第二层是模板渲染层把结论片段套进固定句式生成报告主体的内容。第三层是LLM润色层只负责调整语气、合并重复表达、增加过渡句不允许改动任何数值。这三层各干各的活。分析计算层负责正确性模板渲染层负责稳定性LLM润色层负责可读性。把模型的职责限定在表达而不是思考是这套系统能落地的最关键设计。3. 分析动作库报告里的每一句话背后都是一个计算函数3.1 从裸数字到结论级片段如果你把一张宽表直接丢给大模型让它写报告它写出来的句子往往是看起来通顺、实际数字乱飞。原因是模型并不真正理解表格里每个数字的业务含义它只是在做概率组合。所以我要求报告里的核心结论必须由确定性的函数先算出来。我把这些函数统称为分析动作库。每个动作接收查询结果宽表和参数输出一个结论片段。结论片段是一个结构化的对象包含断言、证据、置信度。我封装了四个最常用的动作同环比分析对时间序列求环比、同比输出变化方向和幅度。比如销售额环比下降8.2%同比上升3.1%。TopN拆解按维度排序计算贡献度。比如华东区贡献了整体下降的43%这个43%是函数算出来的不是模型编的。占比分析计算各分项占总体的比例标出最高项。比如生鲜品类销售额占比31.4%排名第一。异常检测用均值±2σ或IQR方法找异常点标出异常日期与偏离幅度。比如3月27日单日销售额低于均值2.4个标准差。每个结论片段落地为这样的对象dataclass class ConclusionFragment: conclusion: str # 一句确定性断言 evidence: dict # 支撑数据列表 chart_config: dict # 图表渲染配置 confidence: float # 0-1置信度 source_query_id: str # 数据追溯ID这段代码的价值在于结论的生成和表述是可追溯的。哪句话来自哪个查询哪一个计算函数在报告里都会记录。后续如果发现结论有问题可以直接回溯到源头。3.2 结论片段的评分与准入不是所有数字都配写进报告做了一版之后我发现如果不加准入条件系统会把一堆噪声级的数字也写成结论。比如某个城市只有两家门店一家店因为装修停业导致销售额下降50%系统就会把它列为显著下降这其实没有分析价值。我加了三道准入关卡第一关是样本量。分组内记录数少于阈值就不给结论权限只给参考值标签。比如门店维度至少要有5家门店才做门店排名结论。第二关是数据完整度。缺失率超过30%的字段不参与占比和变化分析。第三关是置信度阈值。异常检测的偏离幅度要达到标准差的1.5倍以上才允许标记为异常。数值格式化也是踩坑重灾区。我统一用一套格式化工具不再让每个函数各写各的金额超过1亿显示X.XX亿超过1万显示X.X万其他按千分位。同环比统一一位小数-8.2%占比统一两位小数23.45%。净利润为负和利润下降必须分开表述前者说亏损X后者说下降X%避免歧义。这些细节决定了报告的专业度。同一份数据格式乱一乱读起来就像实习生整理的格式统一了才像结论可靠的分析文档。3.3 归因建议的形成只在证据链内给建议报告中归因建议部分最难做也最容易翻车。我给LLM的规则是归因建议只能引用已有结论片段和证据不允许引入新数据。比如结论片段表明华东区下降主要由江苏门店下滑导致生成建议时可以说建议优先排查江苏门店的运营问题如果结论片段里没有提到华南区系统就绝不能生成建议关注华南区这种话。这个约束在实现上就是把结论片段列表作为LLM输入的限定上下文Prompt里明确写仅基于以下结论片段给出建议。我会在下一章详细讲Prompt设计。4. 文案生成模板表达与LLM润色谁来决定报告的语气4.1 模板是底线LLM是润色不能反过来报告生成的文案部分我采用模板打底LLM润色的组合。模板负责所有涉及数字的句子LLM负责段落间的组织与语气调整。这个顺序不能反过来。如果让LLM先写再去查数字对不对会非常痛苦。模板长什么样看一个总览段模板在{time_range}内{metric}达到{value}环比{trend_mom}同比{trend_yoy}。 从{primary_dimension}来看{top_item}以{top_ratio}的占比排名第一 其中{highlight_item}的变化最为显著{highlight_change}。这些占位符全部由结论片段中的确定性值渲染。渲染完成后进入LLM润色阶段。润色阶段输入的不是表格而是已经渲染好的含占位符文本结论片段列表。我实践下来模板润色的组合比纯LLM生成快很多。模板渲染耗时才几十毫秒LLM只需要润色段落级文本1到2秒内能完成。如果是直接让模型从数据写整篇报告不仅耗时10秒以上数字一致性还很难保障。4.2 多指标联动的叙事编排先结论、后事实、再归因报告不是事实清单的堆砌它需要一条叙事线。我按三段式编排总览结论 → 拆解证据 → 归因建议。总览结论放在最前面说清楚这期整体表现怎么样。拆解证据跟进用TopN、占比、异常点支撑总览结论。归因建议收尾基于证据给出可执行的建议。举个例子某个周报的叙事编排总览上周销售额环比下降8.2%同比上升3.1%整体表现弱于前一周。拆解下降主要由华东区贡献占总下降的43%其中江苏门店下滑最显著-18.6%品类维度生鲜下滑5.2%是主要拖累项。归因建议优先关注江苏门店客流变化以及生鲜供应链的到货稳定性。这个顺序我固定下来逻辑是先让读者了解整体再解释局部差异最后给出行动指引。LLM润色时只能调整过渡词和句式不能改变这个叙事顺序。4.3 润色Promot的设计要点只给换说法的话术不给加戏的空间这里是我踩坑最多的地方。最初的Prompt写得太开放LLM自由发挥严重出现过把环比下降8.2%改成环比大幅下降10%这种事故。后来我把Prompt改成强约束结构你是一名润色助手。你收到的内容包括 1. 渲染好的报告正文段落含{{占位符}}。 2. 支撑这些段落的结论片段列表。 你的任务 - 仅调整句子的通顺度和段落间的衔接不得改动任何数值。 - 不得新增结论片段中不存在的指标、数字或建议。 - 如果两段表达重复合并成一段。 - 输出完整的Markdown格式报告正文。同时我在润色输入里把关键数字替换成占位符。也就是LLM看到的不是8.2%而是{{num_sales_mom_pct}}。它可能误改一个普通数字但绝不会去改一个占位符。润色完成后再由渲染器统一回填。这个先占位后回填的机制几乎把数字改写风险清零了。5. 图表与排版让报告可读、可用、可下钻5.1 图表类型推导规则分析动作与图表的映射报告里没有图表的纯文字是不可接受的但图表也不能乱配。我维护一张分析动作与图表类型的映射表分析动作默认图表条目数约束趋势分析折线图时间点少于3个用表格占比分析环形图条目数小于3或大于10改用排名表维度排名柱状图只展示TopN其余归入其他相关关系散点图样本量少于20不展示地域分布区域地图仅当存在地理维度时启用条目数约束很重要。占比分析如果分了15个品类用环形图根本看不清这时候就要截断为TopN其他比硬画一张饼图有用得多。这些规则我放在图表模块配置里需要时按报告类型覆盖。5.2 从计算函数到前端渲染的一站式转换图表数据必须来自报告数据快照不允许模型生成。做法是在每个结论片段里带上chart_config字段字段里是完整的ECharts配置。前端拿到区块列表直接用配置渲染不做二次计算。一个柱状图配置示例{ chart_type: bar, title: 各省份销售额排名, data: [ {省份: 江苏, 销售额: 12840000}, {省份: 浙江, 销售额: 9860000}, {省份: 安徽, 销售额: 7640000} ], x_field: 省份, y_field: 销售额, value_formatter: wan, top_n: 5 }报告组装采用区块化结构。每个区块包含一个文本段落可选、一个图表配置可选、一个结论片段引用必选。前端拿到区块列表即可渲染后端不需要生成整张HTML维护成本低。5.3 导出格式与分页的取舍报告最终要交付给用户实际会遇到三种格式需求聊天窗口内展示、邮件附件、PDF汇报材料。我按使用场景做了取舍聊天窗口内用Markdown轻量、快、手机也能读图表以图片形式嵌入。邮件附件用HTML可以内嵌图表样式可控适合周报邮件。PDF汇报材料用HTML转PDF但中文场景必须处理中文字体缺失和分页问题。我实测过一次中等复杂度报告的导出耗时大约6秒分布如下阶段耗时请求解析与骨架识别120ms查询编排与数据回源1.8s分析动作计算650ms模板渲染LLM润色2.3s图表渲染与导出1.2s这个耗时对于周报生成来说完全可以接受。用户点一下生成去倒杯水回来就能拿到成品报告。6. 数据一致性校验报告可以被信任的前提6.1 数字复读校验报告里的每个数字必须能在快照里追溯报告最怕的不是写得差而是写错。一个错误数字进了汇报文档责任问题很大。所以我在生成管线最后加了一道数字一致性校验器。校验逻辑很简单解析最终报告文本中所有数字单位的片段然后到报告数据快照里查这个数字是否有对应的来源。如果一个数字在快照里找不到来源直接拦截这次生成重新回到文案阶段生成一次。def validate_numbers_in_report(report_text, snapshot): numbers extract_number_units(report_text) # 正则提取数字单位 allowed snapshot.all_numeric_values() # 快照中的合法数值集合 violations [] for num, unit, context in numbers: if not allowed.has_match(num, unit, context): violations.append((num, unit, context)) return violations实际实现里有两个难点。一是数字单位混写中文报告里12.3个百分点说的是百分点差值不是比例校验时容易误报。我的处理方式是优先校验带关键指标上下文的数字对非关键数字做宽松校验。二是单位换算1.23亿和12,345,678是同一个数字快照里可能是原始值报告里是格式化后的值。所以我要求校验器先做单位归一化再比对。6.2 三个针对报告功能的回归用例我用一组虚构的零售数据做了回归测试专门验证报告的正确性。三个用例代表三类典型的翻车场景。用例A请求生成上周销售周报校验报告里所有数字都能在上周数据快照中找到来源。这个用例保证基本的数据可追溯性。用例B故意诱导模型写一句没有数据支撑的建议比如建议关注华北区门店。校验器应拦截该输出。这个用例验证建议必须在证据链内的约束是否生效。用例C把报告中的环比方向和数值与快照计算值做对照确认LLM润色没有改动数字。这个用例用来验证占位符机制的有效性。这三个用例已经跑进CI流程每次改动生成管线的代码都会执行一遍。我也建议任何做类似系统的团队都建这么三个用例能挡住绝大部分回归问题。6.3 图表与文本结论对不上怎么办图表数据和文本结论如果来自两个渠道就一定会出现对不上的情况。我在设计上做了一个约束图表配置和文本段落都引用同一个结论片段的ID而不是各自保存一份数值。也就是说区块里记录的是conclusion_fragment_idSC_001渲染图表时从快照里取SC_001的evidence渲染文本时从快照里取SC_001的conclusion。即使LLM润色时调整了句子的语序甚至把某句话从段落A挪到段落B图表数据也不会跟着跑偏因为数值的唯一来源是快照而不是文本。7. 实测效果、调参与踩坑记录7.1 一次典型周报的实测表现用一组模拟的零售经营数据覆盖华东区五个省份、六个品类、两百多家门店来测试请求是生成一份华东区上周销售复盘报告。系统最终产出的报告包含总览段落、省份排名图、品类占比图、异常门店列表总共9个区块耗时约6秒。报告质量三个维度评估正确性抽查12个关键数字全部与快照一致。覆盖率快照里能提取的有效结论片段有17个报告引用了14个覆盖率约82%还有提升空间。可读性去掉LLM润色后直接看模板版明显生硬润色后的版本读起来接近人工撰写的周报。覆盖率指标是我自己加的衡量标准。它不完美但比看报告长不长更能反映报告是否把该说的都说全了。快照里明明有生鲜品类连续三周下滑这个重要结论报告里却没提说明编排逻辑有遗漏。我会通过调整动作库参数来优个覆盖情况。7.2 踩过的三个坑占位符、单位口径、长文丢失坑一最好笑也最危险一个数字在报告里出现多次LLM润色时把其中一处从8.2%改成了10%其他几处没动。整个报告数字自相矛盾如果没发现就发出去了。解法就是前面说的占位符机制让LLM在润色阶段根本看不到原始数字。坑二是单位口径不统一。最初有的函数直接输出元有的输出万元报告里同时出现12345万元和123,450,000元非常丢人。解法是所有函数输出统一用标准数值对象包含数值、单位、精度渲染时再做最终换算不在函数内部各自格式化。坑三是报告太长时上下文丢失。报告超过10个区块后LLM在润色靠后的段落时已经记不清开头写了什么经常出现开头说整体下滑结尾又说整体平稳的自相矛盾。解法是报告分块生成每块只润色本块的结论片段最后做一次全局查重相似度超过阈值就自动合并或删改。7.3 下一步可以怎么扩展报告生成做稳之后扩展方向其实很多。比如往报告中加入自然语言查询的追问链路用户看完报告问江苏为什么跌这么多系统能基于同一份数据快照继续拆解。比如把报告快照按天做缓存相同参数的报告二次生成直接跳过数据库回源耗时能压到3秒以内。再比如支持自定义报告模板让业务团队自己定义周报的章节结构而不是用系统预设的四类骨架。我自己的体会是报告生成这部分最大的难点不在生成而在把关。模型负责表达系统负责正确这句话怎么强调都不过分。如果你也在做类似的问数系统建议先不要急着上模型生成整篇报告先把确定性计算这层做扎实再考虑让模型来润色。数据不一致的问题等到上线后再解决就很被动了。
阅读完成 · 觉得有帮助?