在前面的智能化改造中,我们已经让孢子记账具备了预算生成、财务建议、OCR 识别等能力。接下来,我们继续回到一个用户高频使用、但体验仍然偏"工具化"的功能,报表模块。
目前孢子记账的报表模块主要负责返回结构化统计数据,例如月度收支列表、分类支出占比、预算进度百分比等,然后由前端基于这些数字完成图表渲染。从功能角度看,这套机制已经能够回答"这个月收入多少、支出多少、预算用了多少"这些问题,但从用户体验角度看,它仍然停留在"展示数字"的阶段。用户看到的是一组组金额、比例和趋势线,却很难直接理解这些数字背后到底意味着什么,这个月支出是否异常?哪类消费正在悄悄失控?预算进度是健康还是危险?相比上个月,自己的财务状态是在变好还是变差?
因此本篇文章要做的不是简单地给报表页面再增加几个图表,而是为报表增加一层智能解读能力。我们希望系统不只是把数据摆出来,还能像一位懂业务、懂用户的财务助手一样,对报表结果进行概括、分析和提醒,把冷冰冰的数字转换成用户看得懂、愿意看、也能据此行动的解释性内容。换句话说,这次改造的目标,是让报表从"数据展示"升级为"数据理解"。
一、 这次改造的核心目标
这次新增内容的主线,是把报表模块从"返回一组数字"升级成"返回一段可理解的财务判断"。原来的报表接口更像一个数据源,它负责把收入、支出、分类占比、预算使用率等统计结果查出来,然后交给前端自己画图。这个设计在传统报表场景里没有问题,因为报表的核心任务就是提供数据,但当我们希望报表具备"解释能力"时,仅仅返回数字就不够了。前端即使能画出漂亮的折线图和饼图,也很难凭空判断这些数字应该被解释成"健康""注意""风险"还是"已经超支"。
所以本次改造并不是替换原有报表接口,也不是把图表渲染逻辑搬到后端,而是在原有报表数据之上新增一层智能解读能力。旧接口继续负责提供原始数据,保证现有页面和统计能力不受影响,新接口则负责把这些数据进一步组织成一份可阅读、可展示、可行动的财务分析结果。这样一来前端既可以继续使用原始报表数据绘制图表,也可以在图表旁边展示系统生成的总结、风险提醒和行动建议,让报表页面从"看数字"变成"读结论"。
这层智能解读主要解决三个问题。第一个问题是结论提炼 ,也就是从大量明细数字中提炼出一句用户能快速理解的总评,例如"本月整体收支保持平衡,但餐饮支出增长较快"。第二个问题是状态分级 ,也就是把收入变化、支出变化、预算使用率等指标映射成健康等级,方便前端用颜色、标签或提示样式表达当前状态。第三个问题是行动建议,也就是在发现风险后,不只是告诉用户"你超支了",而是进一步说明风险来自哪里、下一步可以怎样调整。
为了让这套能力既适合规则引擎生成,也适合 LLM 增强,同时还能被前端稳定消费,我们没有把接口设计成简单返回一段自然语言文本。自然语言虽然读起来灵活,但对于前端来说并不好拆分展示,也不方便控制不同区域的视觉层级。比如总评应该放在顶部,关键指标应该进入指标卡片,风险项应该使用更醒目的样式,建议项则更适合放在操作提示区域。如果后端只返回一整段文字,前端就很难可靠地区分这些内容。
因此这次设计的核心响应模型是 ReportInsightResponse.cs。它把一份报表解读拆成"概括结论、健康等级、核心指标、亮点、风险、行动建议"等几个固定区域。规则引擎可以按照同一个结构填充内容,LLM 也可以在这个结构上做增强和润色,前端则只需要按照字段渲染即可。它不是只返回一段文本,而是返回结构化内容:
csharp
public class ReportInsightResponse
{
public string InsightType { get; set; } = string.Empty;
public string DataPeriod { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string HealthLevel { get; set; } = string.Empty;
public string GeneratedBy { get; set; } = "Rule";
public DateTime GeneratedAt { get; set; } = DateTime.Now;
public List<ReportInsightMetricResponse> Metrics { get; set; } = new();
public List<ReportInsightItemResponse> Highlights { get; set; } = new();
public List<ReportInsightItemResponse> Risks { get; set; } = new();
public List<ReportInsightItemResponse> Suggestions { get; set; } = new();
}
这里最重要的是响应被拆成了几个前端容易消费的区域。Summary 用来展示一句话总评,HealthLevel 用来驱动颜色或状态标签,Metrics 保留关键数字,Highlights、Risks、Suggestions 则负责把数字解释成用户能行动的内容。
二、 新增的两个智能解读入口
1.1 普通报表解读入口
普通报表新增入口在 ReportController.cs。这里有一个比较重要的设计取舍,原来的 GetReport 没有被改掉,仍然返回原始报表数据,新增的是 GetReportInsight。这样做的目的,是把"数据查询"和"智能解读"拆成两个相互独立的能力。
原来的 GetReport 面向的是图表渲染场景,前端需要拿到相对原始、相对完整的统计数据,然后根据页面需求绘制柱状图、折线图、饼图或明细列表。如果直接修改这个接口的返回结构,把智能解读内容也塞进去,虽然短期看起来少加了一个接口,但会带来两个问题。第一,旧前端可能因为响应结构变化而受到影响,第二,报表数据和解读内容的生命周期并不完全一致,用户有时只需要刷新图表,有时只需要重新生成解读,把两者绑在一起反而会让接口职责变得模糊。
因此我们选择新增 GetReportInsight。它接收的仍然是报表查询请求 ReportInsightRequest,里面包含年份、月份、报表类型等条件,用来告诉后端要解读哪个统计周期的数据。Controller 层并不直接参与画像构建、规则判断或 LLM 调用,它只负责接收请求、调用 _reportServer.GetReportInsightAsync(request),再把生成好的 ReportInsightResponse 返回给前端。这样 Controller 保持得很薄,真正的业务逻辑仍然沉在 Service 层,后续如果要调整画像算法或解读策略,也不需要改动接口入口。新增接口代码如下:
csharp
[HttpPost]
[Route("GetReportInsight")]
public async Task<ActionResult<ReportInsightResponse>> GetReportInsight([FromBody] ReportInsightRequest request)
{
var insight = await _reportServer.GetReportInsightAsync(request);
return Ok(insight);
}
这个设计比较克制。它没有强行改变旧接口的返回结构,所以现有前端图表不会被破坏。新的页面模块如果需要"智能解读",再单独调用 POST /api/Report/GetReportInsight。
1.2 预算报表解读入口
预算报表新增入口在 BudgetReportController.cs。它同样保留原来的预算进度和预算趋势接口,只额外增加 budget-insight。之所以把预算解读入口放在预算报表 Controller 中,而不是统一放到普通报表 Controller 里,是因为预算分析和普通收支报表的关注点并不完全一样。
普通报表更关注"这个周期发生了什么",例如收入多少、支出多少、分类占比如何、相比上一个周期有没有增长。预算报表更关注"当前使用进度是否合理",它天然带有计划和约束的含义。用户查看预算页面时,真正关心的往往不是某个分类已经花了多少钱,而是这个花费是否超过了当初设定的额度、剩余预算还能支撑多久、哪些分类已经接近危险线、后续消费是否需要主动收紧。因此预算智能解读不能只复用普通月报的收支判断逻辑,而是需要围绕预算总额、已使用金额、剩余金额、使用率和趋势变化来生成结论。
保留原来的预算进度和预算趋势接口,也有同样的兼容性考虑。预算进度接口继续负责给前端展示每个分类的预算使用情况,预算趋势接口继续负责展示一段时间内的消耗变化,而新增的 budget-insight 则站在这两类数据之上,给出一份更接近"预算顾问"的解释。前端可以继续用旧接口渲染进度条和趋势图,再调用这个新接口展示总结卡片、风险提示和调整建议。
这个接口使用 HttpGet,是因为当前预算解读并不需要前端额外提交复杂查询条件,而是基于当前用户、当前账本或系统已有预算上下文生成结果。Controller 层同样保持简洁,只调用 _budgetReportServer.GetBudgetInsightAsync(),真正的数据汇总、画像构建和解读生成都交给预算报表服务完成。新增接口代码如下:
csharp
[HttpGet]
[Route("budget-insight")]
public async Task<ActionResult<ReportInsightResponse>> GetBudgetInsight()
{
ReportInsightResponse insight = await _budgetReportServer.GetBudgetInsightAsync();
return Ok(insight);
}
这个接口的语义是,前端不再只问"预算用了多少",而是问"当前预算状态是否健康、有没有接近超支、哪些分类需要关注"。
三、 报表画像是智能解读的中间层
真正让系统从"数字展示"走向"数据理解"的关键,是新增的画像模型 ReportInsightPortrait.cs。它不是一个简单的 DTO,而是整个智能解读流程里的中间层。前面的 Controller 只负责暴露接口,后面的规则引擎和 LLM 只负责生成解读,而画像层则负责把原始报表数据整理成一份适合分析的"财务上下文"。
为什么需要这个中间层?原因很简单,原始报表数据通常只适合展示,不一定适合直接判断。比如普通报表里可能是一组分类金额,预算报表里可能是一组预算进度,趋势报表里又是另一种时间序列结构。如果规则引擎直接依赖这些原始返回模型,那么每增加一种报表类型,规则引擎就要理解一种新的数据结构,如果 LLM Prompt 直接拼接这些原始对象,也会导致输入格式越来越混乱。画像层的作用,就是把这些不同来源、不同形态的数据先收拢成统一的分析模型。
有了 ReportInsightPortrait 以后,系统的职责边界会清晰很多。报表服务负责查询数据和构建画像,规则引擎负责根据画像做确定性判断,LLM 负责在规则结果基础上做表达增强,前端只消费最终的 ReportInsightResponse。这相当于在"数据查询"和"智能生成"之间加了一层缓冲区,既降低了后续扩展的复杂度,也避免 LLM 或规则逻辑直接被数据库查询细节污染。
对于普通报表来说,画像构建发生在 ReportServerImpl.cs 的 GetReportInsightAsync 方法中。这个方法不是简单地查询当前月份数据后立刻生成结论,而是先同时准备当前周期和上一周期的数据,再交给 BuildReportPortrait 统一整理:
csharp
public async Task<ReportInsightResponse> GetReportInsightAsync(ReportInsightRequest request)
{
var currentReports = await BuildPeriodQuery(request.Year, request.Month, request.ReportType)
.ToListAsync();
var previousPeriod = GetPreviousPeriod(request.Year, request.Month, request.ReportType);
var previousReports = await BuildPeriodQuery(previousPeriod.Year, previousPeriod.Month, request.ReportType)
.ToListAsync();
var portrait = BuildReportPortrait(request, currentReports, previousReports);
return await _reportInsightGenerator.GenerateAsync(portrait);
}
这段代码看起来很短,但它决定了普通报表智能解读的分析深度。第一步,BuildPeriodQuery(request.Year, request.Month, request.ReportType) 查询当前周期报表数据,这是报表解读的主体。第二步,通过 GetPreviousPeriod 计算上一个统计周期,再查询上一周期报表数据,这是为了给当前数据提供对照组。第三步,BuildReportPortrait 把当前周期和上一周期合并成画像,再交给 _reportInsightGenerator.GenerateAsync(portrait) 生成最终解读。
这里最关键的是"对照组"这个设计。如果只有当前周期数据,系统最多只能说"本月收入多少、支出多少、结余多少"。但加入上一周期之后,系统就能进一步回答"收入是否下降、支出是否上升、结余是否变差、变化幅度是否值得提醒"。报表解读真正有价值的地方,往往不只是描述当前状态,而是发现变化趋势。用户看到"本月支出 5000 元"未必有感觉,但如果系统告诉他"本月支出比上月增加 35%,主要增长来自餐饮和购物",这就变成了可理解、可行动的信息。
画像构建时会计算收入、支出、结余、收入变化率、支出变化率、结余变化额以及分类占比。这些字段分别对应报表解读中的不同问题,收入和支出回答"钱从哪里来、花到哪里去",结余回答"这个周期是否还有剩余",变化率回答"相比上期是改善还是恶化",分类占比则回答"哪一类消费最值得关注"。核心计算逻辑如下:
csharp
decimal income = currentReports.Where(IsIncomeReport).Sum(p => p.Amount);
decimal expense = currentReports.Where(IsExpenseReport).Sum(p => p.Amount);
decimal balance = income - expense;
var categories = currentReports
.Where(IsExpenseReport)
.GroupBy(p => string.IsNullOrWhiteSpace(p.Name) ? p.TransactionCategoryId.ToString() : p.Name)
.Select(group => new ReportInsightCategoryMetric
{
CategoryName = group.Key,
Amount = group.Sum(p => p.Amount),
Percentage = expense <= 0 ? 0 : group.Sum(p => p.Amount) / expense
})
.OrderByDescending(p => p.Amount)
.ToList();
这段代码先把当前周期报表拆成收入和支出两组,再分别求和得到 income 和 expense。balance 则是收入减支出,它是判断财务状态最直观的指标。只看收入或支出都可能产生误判,比如收入高但支出更高,用户依然可能处于负结余状态,支出高但收入同步增长,也未必意味着风险。因此画像中必须同时保留收入、支出和结余,让后续规则引擎可以从整体现金流角度进行判断。
分类占比的计算同样重要。报表智能解读不能只停留在"本月支出偏高"这种笼统描述上,而应该进一步指出支出主要集中在哪些分类。代码中先筛选支出类报表,再按分类名称分组,计算每个分类的总金额和占整体支出的比例,最后按金额倒序排列。这样后续生成风险和建议时,就可以优先关注金额最高、占比最高的分类。例如餐饮占比明显偏高时,系统可以提醒用户关注外食频率,购物分类突然上升时,系统可以提示近期是否存在集中消费。
画像里还会保存当前周期和上一周期之间的变化信息。收入变化率可以帮助系统判断收入端是否出现波动,支出变化率可以识别消费是否突然上升,结余变化额则能体现用户整体现金流是否变好。相比单纯展示当前数字,变化指标更适合触发提醒。比如本月结余仍然为正,但相比上月减少很多,这时系统不应该简单地给出"健康"结论,而应该提醒用户留意支出增长趋势。
这里还有一个当前阶段的实现取舍,Report 实体里没有明确的收入/支出方向字段,所以现在用 Name 中的"收入、入账、income、revenue"等关键词识别收入,其余默认作为支出。这个方案能让功能先跑起来,也适合在文章示例中快速说明完整链路,但它并不是长期最理想的数据契约。更稳妥的做法,是在报表数据或交易分类数据里提供明确的方向字段,例如 Income、Expense 或类似枚举。这样画像构建时就不需要依赖名称关键词,规则判断和 LLM 输入也会更加可靠。
从架构角度看,这个取舍也说明了画像层的另一个价值,即使底层数据暂时不够完美,我们也可以把兼容和修正逻辑集中在画像构建阶段,而不是让规则引擎、Prompt 构建器和前端都去处理同一个问题。后续一旦数据契约补齐,只需要调整 BuildReportPortrait 中收入支出的识别方式,后面的智能解读流程仍然可以保持不变。
四、 预算画像复用了现有预算进度和趋势
预算智能解读没有重新发明一套预算计算逻辑,而是复用了已有的两个报表结果。这个设计很重要,因为预算模块本身已经有成熟的进度计算和趋势统计,如果为了智能解读再单独写一套预算汇总逻辑,就会出现两套口径并存的问题。前端预算进度条显示的是一套数据,智能解读使用的是另一套数据,时间一长很容易出现"页面显示还剩 200 元,但解读却提示已经超支"这类体验问题。
因此预算画像的构建思路是,先复用现有预算报表能力,拿到系统已经认可的预算进度和预算趋势,再把它们整理成 ReportInsightPortrait。这样智能解读不是重新计算预算,而是在既有预算结果之上做解释。它的职责从"算账"变成"读账",预算进度告诉系统当前用了多少,预算趋势告诉系统消耗速度是否异常,画像层再把这些信息合并成可以被规则引擎和 LLM 理解的上下文。核心代码在 BudgetReportServerImpl.cs:
csharp
public async Task<ReportInsightResponse> GetBudgetInsightAsync()
{
List<BudgetProgressReportResponse> progressReports = await GetBudgetProgress();
List<BudgetConsumptionTrendReportResponse> trendReports = await GetBudgetConsumptionTrend();
ReportInsightPortrait portrait = BuildBudgetPortrait(progressReports, trendReports);
return await _reportInsightGenerator.GenerateAsync(portrait);
}
这段代码的流程非常直接。GetBudgetProgress() 返回当前预算周期内各分类的预算总额、已使用金额、剩余金额和使用率,它解决的是"预算当前执行到哪里了"的问题。GetBudgetConsumptionTrend() 返回预算消耗趋势,它解决的是"预算消耗速度有没有变化"的问题。最后 BuildBudgetPortrait(progressReports, trendReports) 把这两类数据合并成统一画像,再交给 _reportInsightGenerator.GenerateAsync(portrait) 生成解读。
这里的关键不是代码有多复杂,而是复用边界很清晰。预算进度和趋势仍然由原来的预算报表服务负责,智能解读只消费这些结果,不改变它们的计算方式。这样做有三个好处。第一,预算展示和预算解读使用同一套数据口径,用户看到的进度条、趋势图和文字提醒不会互相矛盾。第二,后续如果预算计算逻辑优化,例如加入周期折算、共享预算或多账本预算,智能解读可以自动受益。第三,规则引擎和 LLM 不需要理解预算原始明细,只需要面对整理后的画像,逻辑会更稳定。
预算画像里最关键的是综合预算使用率和分类预算使用率。综合预算使用率回答的是"整体预算是否健康",分类预算使用率回答的是"风险主要集中在哪些分类"。核心计算如下:
csharp
decimal totalAmount = comprehensive?.TotalAmount ?? progressReports.Sum(p => p.TotalAmount);
decimal usedAmount = comprehensive?.UsedAmount ?? progressReports.Sum(p => p.UsedAmount);
decimal remaining = comprehensive?.Remaining ?? progressReports.Sum(p => p.Remaining);
BudgetUsageRate = totalAmount <= 0 ? 0 : usedAmount / totalAmount
这里优先从 progressReports 中查找综合预算项 comprehensive。如果已经存在综合预算结果,就直接使用它的 TotalAmount、UsedAmount 和 Remaining,如果没有综合项,则退回到各分类预算求和。这是一种比较稳妥的兼容策略,因为不同系统阶段或不同用户配置下,预算数据不一定总是包含综合预算行。通过这种方式,画像层可以尽量保证后续规则引擎拿到完整的总预算、已使用金额和剩余金额。
BudgetUsageRate 是预算解读中最核心的指标,它等于已使用金额除以预算总额。这个值本身只是一个比例,但它可以被规则引擎翻译成明确状态,低于 70% 通常说明预算还比较从容,超过 70% 就需要关注消费节奏,超过 90% 说明预算接近用尽,超过 100% 则意味着已经超支。用户并不一定知道 0.92 代表什么,但"预算已经使用 92%,后续消费需要收紧"就非常直观。
不过,只看综合预算使用率还不够。一个用户整体预算使用率可能只有 65%,看起来还算健康,但某个分类可能已经用了 120%。比如餐饮预算已经超支,但交通、娱乐预算还没怎么用,综合比例就会把局部风险稀释掉。因此预算画像还会把分类预算整理出来,并按使用率倒序排列:
csharp
var categories = progressReports
.Where(p => !p.IsComprehensive)
.Select(p => new ReportInsightCategoryMetric
{
CategoryName = string.IsNullOrWhiteSpace(p.CategoryName) ? "未命名分类" : p.CategoryName,
BudgetAmount = p.TotalAmount,
UsedAmount = p.UsedAmount,
Remaining = p.Remaining,
UsageRate = p.TotalAmount <= 0 ? 0 : p.UsedAmount / p.TotalAmount,
Amount = p.UsedAmount,
Percentage = usedAmount <= 0 ? 0 : p.UsedAmount / usedAmount
})
.OrderByDescending(p => p.UsageRate)
.ToList();
这段代码会排除综合预算项,只保留具体分类预算。每个分类都会被转换成 ReportInsightCategoryMetric,其中 BudgetAmount 表示分类预算额度,UsedAmount 表示已经使用的金额,Remaining 表示剩余额度,UsageRate 表示该分类自己的预算使用率,Percentage 则表示该分类已使用金额占全部已使用预算的比例。这样同一个分类既有"是否超出自身预算"的视角,也有"在整体预算消耗中占比多少"的视角。
按 UsageRate 倒序排列也很有意义。预算解读的重点不是把所有分类平铺出来,而是优先发现最危险的分类。比如一个分类预算 300 元,已经用了 360 元,使用率就是 120%,它应该排在前面并触发超支提醒,另一个分类预算 3000 元,已经用了 1500 元,金额更大,但使用率只有 50%,它可能并不是当前最紧急的问题。使用率排序能帮助系统优先识别"接近用尽"和"已经超支"的分类。
这样后面的规则引擎就能判断哪些分类已经超支,哪些分类接近用尽,而不是只给一个综合预算百分比。LLM 在增强表达时,也可以基于这些分类指标生成更具体的建议,例如"餐饮预算已经超过计划,可以优先减少外卖和聚餐支出",而不是泛泛地说"建议控制消费"。这就是预算画像的价值,它把预算进度和趋势从展示数据,转化成了可判断、可解释、可行动的分析上下文。
从用户体验角度看,预算画像让系统具备了更接近"预算管理助手"的表达方式。过去用户看到的是一个进度条,知道自己用了多少,现在系统可以进一步告诉他,当前预算状态是否安全,风险集中在哪些分类,接下来应该先调整哪一部分消费。预算报表也因此从"记录预算执行情况"升级成了"辅助用户管理预算节奏"。
五、 规则引擎保证没有 LLM 也能返回稳定结果
规则引擎是 ReportInsightRuleEngine.cs。它是整个功能的稳定底座。无论 LLM 是否启用、是否报错,系统都可以先通过规则生成一份可用解读。这个设计非常关键,因为报表页面属于高频核心页面,用户打开报表时可以接受智能表达没有那么"惊艳",但不能接受页面因为模型超时、网络异常或配置缺失而没有任何结果。
在很多智能化功能里,我们容易把注意力全部放在 LLM 上,认为只要接入模型就完成了智能分析。但在真实业务系统中,LLM 更适合做表达增强、上下文整合和自然语言生成,不能直接承担系统可用性的全部责任。尤其是报表解读这种能力,它的基础判断其实是可以被规则稳定覆盖的。例如结余为负就是风险,预算使用率超过 90% 就应该提醒,某个分类预算超过 100% 就说明已经超支。这些判断不需要模型"推理",用规则反而更可靠、更可控。
因此本次实现采用的是"规则先行,LLM 增强"的方式。规则引擎先基于 ReportInsightPortrait 生成一份完整的 ReportInsightResponse,里面包括 Summary、HealthLevel、Metrics、Highlights、Risks 和 Suggestions。这份结果本身就可以直接返回给前端。后续如果启用了 LLM,模型只是在这份规则结果的基础上做进一步润色和补充,而不是从零开始决定用户看到什么。
规则引擎入口方法很简单:
csharp
public ReportInsightResponse Generate(ReportInsightPortrait portrait)
{
return portrait.InsightType == "Budget"
? GenerateBudgetInsight(portrait)
: GenerateMonthlyReportInsight(portrait);
}
这里根据 portrait.InsightType 做了一次分发。如果画像类型是 Budget,就进入预算解读规则,否则走普通月报解读规则。这样同一个 ReportInsightGenerator 可以统一调用规则引擎,而规则引擎内部再根据画像类型选择不同策略。这个分发点也为后续扩展留下了空间,未来如果要增加年度总结、季度复盘或异常消费解读,也可以继续在这里扩展新的规则分支。
普通月报解读主要关注现金流和消费结构。它会围绕负结余、支出增长、结余下降和分类占比等信号生成判断。这里的核心逻辑不是追求复杂,而是优先覆盖用户最容易关心、也最容易产生行动价值的问题,本期是否入不敷出,支出是否明显增加,结余是否比上期变少,钱主要花在哪些分类上。
例如,当结余为负时,规则会把健康等级标为 Risk,同时生成风险和建议:
csharp
if (portrait.Balance < 0)
{
response.HealthLevel = "Risk";
response.Risks.Add(CreateItem("收支结余", "本期出现负结余", $"本期结余为 {portrait.Balance:F2} 元,支出已经高于收入,需要优先关注现金流压力。", "High"));
response.Suggestions.Add(CreateItem("收支结余", "先稳住必要支出", "建议先区分必要支出和弹性支出,优先压缩可延后的消费,让下个周期的结余回到正数。", "High"));
}
这段代码体现了规则引擎的基本风格,先根据明确指标判断状态,再生成对应的风险项和建议项。portrait.Balance < 0 是一个非常确定的信号,说明本期支出已经高于收入,所以系统直接把 HealthLevel 调整为 Risk。同时,Risks 里会说明风险是什么,Suggestions 里会告诉用户下一步可以怎么做。这样前端拿到的不是一个孤立状态码,而是一组可以直接展示的解释内容。
普通月报规则还会继续结合支出变化率、结余变化额和分类占比做判断。比如支出相比上期明显增长时,可以生成"支出增长较快"的风险提醒,结余虽然仍为正但下降明显时,可以提示"结余空间正在收窄",某个分类占整体支出比例过高时,可以把它加入风险或建议中。这样生成出来的结果虽然来自规则,但已经不再是单纯的数字展示,而是带有基础分析能力的财务解读。
预算解读则主要围绕预算使用率分级。预算场景和普通月报不同,预算本身就带有"计划额度"的含义,所以规则判断会更强调阈值。70% 以上提醒注意,90% 以上进入风险,超过 100% 则标记为超支:
csharp
response.HealthLevel = portrait.BudgetUsageRate switch
{
> OverrunBudgetUsageRate => "Overrun",
>= RiskBudgetUsageRate => "Risk",
>= AttentionBudgetUsageRate => "Attention",
_ => "Healthy"
};
这段分级规则的好处是直观稳定。Healthy 表示预算仍然比较安全,Attention 表示已经进入需要关注的阶段,Risk 表示预算接近用尽,Overrun 则表示已经超出预算。前端可以直接根据这些状态驱动颜色、标签和提示样式,例如健康状态使用正常色,注意状态使用提醒色,风险和超支状态使用更醒目的样式。
预算规则不只判断综合预算使用率,也会结合分类预算使用率生成更具体的提醒。综合预算使用率能说明整体是否紧张,分类预算使用率则能指出风险来源。比如整体预算使用率只有 75%,按综合状态可能只是 Attention,但如果餐饮分类已经达到 110%,系统仍然应该把餐饮超支作为风险项列出来。这样的规则能避免整体指标掩盖局部问题。
从工程角度看,规则引擎还有一个非常实际的价值,就是可预测、可测试、可兜底。给定同一份 ReportInsightPortrait,规则引擎每次都会生成相同结果,这意味着我们可以针对关键场景写单元测试,例如负结余、预算超支、支出增长、分类占比过高等。LLM 输出可能因为模型版本、提示词或温度参数发生变化,但规则结果是确定的。系统在任何时候都可以退回到这条确定路径上。
这就是"财务助手感"的第一层来源。它不只是展示 0.92 这样的比例,而是把它翻译成"预算接近用尽""后续消费需要谨慎"这类用户能理解的话。即使没有 LLM,规则引擎也能让报表从"展示数字"变成"解释数字";有了 LLM 之后,系统只是在这个稳定结果之上进一步提升表达质量,而不是把稳定性交给模型运气。
六、 LLM 增强只负责润色,不承担系统稳定性
LLM 调用被封装在 ReportInsightGenerator.cs。这部分的关键思想是,规则结果永远先生成,LLM 只是增强,不是唯一答案。换句话说,模型在这里扮演的是"表达优化器"和"解释增强器"的角色,而不是整个报表解读功能的唯一执行引擎。
这样设计的原因和前面规则引擎的定位是一脉相承的。报表解读首先要保证稳定,其次才是追求更自然、更有温度的表达。规则引擎已经可以基于画像生成确定性的总结、健康等级、风险项和建议项,这份结果本身就是完整可用的。LLM 介入以后,并不是推翻规则引擎的判断,而是在已有判断基础上,让内容更像一位财务助手写出来的说明。例如规则引擎可能生成"预算使用率较高,请控制消费",LLM 可以结合具体分类、趋势和上下文,把它改写成"本期预算已经接近用尽,尤其是餐饮分类使用较快,接下来可以优先减少外食和临时消费"。因此 ReportInsightGenerator 的第一步不是调用模型,而是先调用规则引擎:
csharp
ReportInsightResponse ruleResult = _ruleEngine.Generate(portrait);
if (!_options.EnableLlm)
{
return ruleResult;
}
这段代码把兜底路径放在了最前面。_ruleEngine.Generate(portrait) 会先生成一份规则结果,并保存到 ruleResult。接下来判断 ReportInsights:EnableLlm 是否启用,如果没有启用,方法直接返回规则结果。这意味着 LLM 是一个可选增强项,而不是必需依赖。即使生产环境暂时没有配置模型服务,或者某些部署环境不希望启用模型调用,报表智能解读接口仍然可以正常工作。
这种开关式设计也方便灰度上线。我们可以先让规则引擎在线上运行一段时间,验证画像构建、健康等级、风险规则和前端展示都稳定以后,再打开 LLM 增强。即使打开之后发现模型输出不理想,也可以通过配置快速关闭,回到规则结果,而不需要回滚代码。即使打开了 LLM,也会先检查 IOpenAIService 是否注册成功:
csharp
var openAIService = _serviceProvider.GetService<IOpenAIService>();
if (openAIService == null)
{
_logger.LogWarning("[报表解读] 已启用 LLM,但未注册 OpenAI 服务,使用规则引擎结果");
return ruleResult;
}
这里没有直接假设 IOpenAIService 一定存在,而是通过 _serviceProvider.GetService<IOpenAIService>() 进行可选解析。如果服务没有注册成功,系统只记录一条警告日志,然后返回规则结果。这一点很实用,因为 LLM 服务注册通常依赖配置项,例如接口地址、模型名称、API Key 等。如果环境变量或配置中心暂时没有准备好,系统不应该因为一个增强能力缺失而影响报表主流程。真正调用模型时,使用的是公共库已有的结构化输出能力:
csharp
string prompt = ReportInsightPromptBuilder.Build(portrait, ruleResult);
var llmResult = await openAIService.ChatStructuredAsync<ReportInsightResponse>(prompt, cancellationToken);
这里先通过 ReportInsightPromptBuilder.Build(portrait, ruleResult) 构造 Prompt。这个 Prompt 不只包含原始画像数据,也会带上规则引擎已经生成的 ruleResult。这样做的好处是,模型不会脱离规则结果自由发挥,而是在后端已经确认过的健康等级、风险和建议基础上进行表达优化。它可以让总结更自然、让建议更具体、让语气更温和,但不应该改变核心判断。
随后调用 ChatStructuredAsync<ReportInsightResponse>,要求模型按 ReportInsightResponse 的结构返回结果。这里继续沿用前面设计的结构化响应模型,而不是让模型返回一段自由文本。这样前端仍然可以稳定读取 Summary、HealthLevel、Metrics、Highlights、Risks 和 Suggestions,不会因为模型写作风格变化而影响页面渲染。
结构化输出还有一个好处,就是后端可以对模型返回值做基本校验。比如模型是否返回了空结果,Summary 是否为空,HealthLevel 是否缺失,列表字段是否为 null。如果模型返回内容不完整,或者结构没有达到接口要求,就不应该把这个结果直接交给前端,而是回退到规则结果。对于用户来说,他宁愿看到一份朴素但正确的规则解读,也不应该看到一份字段缺失、页面渲染异常的模型结果。
如果模型调用过程中出现异常,例如网络超时、鉴权失败、接口限流、模型响应格式异常,代码也会捕获并回退到 ruleResult。这就是"LLM 增强不承担系统稳定性"的真正含义。模型可用时,它提升表达质量,模型不可用时,系统仍然保持原有可用性。报表页面不会因为外部模型服务抖动而失效,用户也不会感知到底层发生了模型降级。
从工程治理角度看,这种设计比"直接把画像发给 LLM,然后完全相信模型结果"要稳得多。规则引擎提供确定性下限,LLM 提供体验上限,两者各自承担适合自己的职责。规则负责"判断不能错得离谱",LLM 负责"表达不要太机械"。这也是业务系统接入大模型时比较推荐的方式,先用工程规则托住核心链路,再让模型在可控范围内提升体验。
七、 配置和依赖注入让功能默认安全启用
依赖注入配置位于 Program.cs。这一节看起来只是几行服务注册,但它其实决定了整个报表智能解读功能的默认启动策略。我们希望这个功能在没有 LLM 配置时也能正常运行,在 LLM 配置齐全时再自动获得增强能力。换句话说,规则引擎是默认能力,LLM 是可选能力,系统启动不应该被可选能力绑架。新增的核心注册包括规则引擎、生成器、预算报表服务,以及 ReportInsights 配置:
csharp
builder.Services.Configure<ReportInsightOptions>(builder.Configuration.GetSection("ReportInsights"));
builder.Services.AddScoped<ReportInsightRuleEngine>();
builder.Services.AddScoped<ReportInsightGenerator>();
builder.Services.AddScoped<IReportServer, ReportServerImpl>();
builder.Services.AddScoped<IBudgetReportServer, BudgetReportServerImpl>();
这里先通过 Configure<ReportInsightOptions> 把 ReportInsights 配置节绑定到选项对象。这个配置主要用来控制报表解读功能的行为,其中最关键的就是是否启用 LLM。把它做成配置项,而不是写死在代码里,是为了让不同环境可以选择不同策略。比如本地开发环境可以只跑规则引擎,测试环境可以打开 LLM 做联调,生产环境则可以在确认模型服务稳定后再逐步开启。
ReportInsightRuleEngine 和 ReportInsightGenerator 使用 AddScoped 注册,说明它们跟随一次请求或一次业务调用作用域创建。规则引擎本身主要负责纯计算和结构化结果生成,生成器则负责协调规则结果、LLM 开关、模型调用和降级回退。把它们注册到 DI 容器里,可以让 Controller 和业务服务不需要手动 new 对象,也方便后续替换实现或增加依赖。
IReportServer 和 IBudgetReportServer 的注册也很关键。普通报表解读入口依赖 IReportServer,预算报表解读入口依赖 IBudgetReportServer,它们分别负责构建普通报表画像和预算画像。也就是说,Controller 层只依赖接口,不关心具体实现类。后续如果要拆分服务、增加缓存、或者把某些画像构建逻辑迁移到独立服务中,接口层仍然可以保持稳定。
LLM 注册是有条件的:
csharp
var reportInsightOptions = builder.Configuration.GetSection("ReportInsights").Get<ReportInsightOptions>()
?? new ReportInsightOptions();
if (reportInsightOptions.EnableLlm && builder.Configuration.GetSection("OpenAI").Exists())
{
builder.Services.AddOpenAIService(builder.Configuration);
}
这段代码先读取 ReportInsights 配置,如果配置节不存在,就使用一个默认的 ReportInsightOptions。这意味着报表解读功能不会因为缺少配置节而直接失败。随后只有在两个条件同时满足时才注册 OpenAI 服务:第一,reportInsightOptions.EnableLlm 必须为 true,第二,配置中必须存在 OpenAI 节。少任何一个条件,都不会注册 LLM 服务。
这个判断避免了一个常见问题,如果服务启动时强制注册 OpenAI,但环境里没有 OpenAI 配置,服务可能直接启动失败。对于一个已有的业务服务来说,这是不划算的。报表智能解读是增强能力,不应该因为模型配置缺失导致整个服务无法启动。现在默认可以只跑规则引擎;等配置中心或本地配置准备好之后,再把 ReportInsights:EnableLlm 打开。
这种注册方式也和前面 ReportInsightGenerator 中的可选解析互相配合。启动阶段只有配置满足条件才注册 IOpenAIService,运行阶段即使 EnableLlm 打开,也会再次检查 IOpenAIService 是否可用。如果不可用,就记录日志并回退到规则结果。这样系统在启动和运行两个阶段都有保护,既避免启动失败,也避免运行时异常扩散到接口响应。
从发布策略上看,这种默认安全启用的方式更适合真实项目。我们可以先把代码发布上线,让规则引擎版本的报表解读先工作起来;然后再补充 OpenAI 配置,打开 ReportInsights:EnableLlm,让同一套接口获得 LLM 增强。整个过程不需要改变前端调用方式,也不需要重新设计接口。对于用户来说,功能始终可用,只是解读文本从规则生成逐步升级为模型增强。
如果后续要把配置放到 Nacos 或其他配置中心,这个结构也比较容易延续。ReportInsights 负责控制报表解读自身行为,OpenAI 负责模型服务接入信息,两者职责分开。报表解读是否启用 LLM,不等于整个系统是否具备 OpenAI 配置;模型平台怎么接入,也不应该污染报表业务配置。这样的配置边界会让后续运维和排查都更清晰。
八、 总结
这次改造已经搭好了"数据到理解"的主路径。前端可以继续用旧接口画图,同时调用新接口展示智能总结、风险提醒和行动建议。预算部分的数据基础比较扎实,因为它能直接拿到预算总额、剩余金额、使用率和趋势。普通报表部分已经能做月度收支和分类占比解读,但收入/支出的判断目前依赖名称关键词,这是后续最值得补强的数据契约点。
整体上,这次新增内容不是简单加了两个接口,而是建立了一个可扩展的解读框架。后面如果要加入年度总结、季度复盘、异常消费识别、用户偏好语气,基本都可以沿着"构建画像、规则判断、LLM 增强、结构化返回"这条路径继续扩展。