从车间主任一句"能不能让电脑直接告诉我上周A线为什么不良率飙升"的需求开始,我在一个老牌的WinForms桌面项目里接入了AI大模型,做了一套生产数据分析助手。这篇笔记记录了整个过程:为什么选WinForms而不是Web或Python、项目架构怎么拆、生产数据如何加工成Prompt、大模型调用的核心实现与异常处理、以及上线前必须考虑的性能和数据安全平衡。内容偏实操,适合正在做类似"传统桌面应用 + AI能力"的.NET开发者参考,也适合想了解AI落地时那些真实坑的人。
1. 为什么偏偏在WinForms里做AI数据分析
1.1 需求起点:车间里那句"能不能直接问我"
事情起源于一个挺朴素的场景。公司一条生产线的管理看板上,每天都滚动着产量、不良率、停机时长、设备OEE这些指标,但整个系统本质上是"人查报表"。老板问一句"这周B线停机为什么比上周多2小时",数据分析员要先打开数据库客户端、写SQL、导Excel、套图表,折腾半小时才能给个大概齐的答复。生产部的同事抱怨了很久,说"报表是死的,问题得靠人想"。
于是项目组就想加一个对话式分析入口。最初讨论过两条路:一是用纯规则和统计算法做"伪智能",把常见问题模板化成SQL查询;二是直接上大模型,让AI理解自然语言、生成结论。第一条路胜在可控,但碰到"为什么""对比一下""你觉得哪个环节最值得优化"这类开放问题就抓瞎。第二条路能力够强,但想在老WinForms项目里落地,顾虑也不少。
这个项目的核心定位很快明确:不是做取代报表系统的AI平台,而是在现有桌面客户端里嵌入一个"生产数据分析助手",让操作工和分析员可以用自然语言问数据,AI负责理解问题、查数、分析、生成带结论的答复。项目的技术关键词有两个:AI和WinForms。前者决定了能力上限,后者决定了实现姿势——这是一个跑在内网、部署在Windows工控机上的桌面应用,不是公网Web服务。
1.2 团队讨论时三个方案怎么取舍
第一个方案是搭一个Python后端做AI服务,WinForms客户端通过HTTP调用。这个方案的优点是Python生态好,做数据处理和模型调用都方便,团队里写Python的人也熟。缺点是内网环境多了一套Python服务要维护,部署在工控机上还多了一层进程守护和依赖管理,对IT运维不友好。
第二个方案是干脆把客户端改成WPF甚至Electron,顺便把UI重构一遍。这方案被否得最快,因为现有WinForms项目有大量成熟的第三方控件和业务代码,重构UI周期长、风险高,而且车间工控机配置普遍不高,Electron那套内存占用确实扛不住。
第三个方案就是现在落地的:客户端仍然是纯WinForms,.NET Framework 4.7.2,AI能力作为一个服务层封装在客户端进程内部,通过HTTP调用本地或远程的大模型推理服务。选这个方案的核心原因有三点:一是改动面最小,能在不重建UI的前提下把AI能力嵌进现有工程;二是数据不出内网,敏感的生产数据不需要经过公网;三是部署简单,客户端还是原来的安装包,多出来的只是一个模型服务的配置项。
有个容易忽略但很重要的点:WinForms虽然老,但在工控和企业内网场景里反而是一种优势。Form窗体、DataGridView、定时器这些控件的启动速度和内存占用都很可控,尤其是在Windows 7到Windows 10并存、部分机器还在用机械硬盘的环境里,WinForms的先天轻量能省掉很多麻烦。
1.3 一个反直觉的结论:AI服务到底放哪
最开始团队以为AI服务必须独立部署,但后来发现不完全是。模型推理服务确实要独立进程,但中间那一层"分析服务"可以有两个选择:一是嵌在WinForms客户端里,二是放在局域网中央服务器。
放到中央服务器的好处是多个客户端共享同一份模型、同一份日志,还能统一做数据脱敏和权限控制;坏处是车间网络如果出现波动,整个分析功能就瘫痪了,而且中央服务器的CPU和内存压力会比较大。放到客户端本地的好处是单机可用、隔离性和稳定性都强,但每台机器都要拉一份模型数据,模型更新时也是个麻烦。
我们最终选择的是"本地推理服务 + 中央配置下发"的混合模式。每台工控机装Ollama服务,模型用qwen2.5的7B量化版,占用大约5GB磁盘和4GB内存,车间机器基本都能扛住。客户端启动时从中央接口拉一份"模型服务配置",如果本机Ollama连不上,自动尝试连接备用地址。这样既保证了离线可用性,也避免了把所有鸡蛋放在一个篮子里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目架构拆解:老框架如何装下新智能
2.1 三层结构:数据层、分析层、AI适配层
先说整体结构。这个项目的代码分了三层,不是传统意义上的三层架构,而是按"数据在哪里、逻辑在哪里、AI在哪里"来划分的。
数据层负责访问生产数据库。数据库是SQL Server,里面有产量表、不良记录表、设备停机表、工单表这几张核心表。数据层用Dapper做轻量ORM,避免引入EF那种重量级框架。每次AI分析需要数据时,数据层根据分析层下发的参数去查询,返回DataTable或List
分析层是核心,它干三件事:一是把用户问的自然语言问题做预处理和意图分类;二是根据问题意图决定调哪些数据、查哪些字段;三是把查询结果加工成"模型友好的上下文",拼进Prompt。这一层不关心模型具体是怎么调用的,只关心"给模型什么内容"。
AI适配层是最薄的一层,封装了所有与大模型推理服务的HTTP通信。包括请求超时、重试、流式接收、结果解析。之所以单独抽一层,是因为想保留切换模型后端的能力——本地用Ollama,也可以切到任意OpenAI兼容接口,只需要改配置,不动业务代码。
这样分层之后,最大的收益是「可测试性」。分析层的Prompt构造逻辑可以本地单测,AI适配层可以用Mock数据模拟模型返回,不需要每次调试都真的调一次模型,省了很多算力和时间。
2.2 AI适配层核心设计:一个几乎没人愿意多写的接口
AI适配层的核心接口很简单,简单到可能有人觉得没必要抽象:
csharp复制public interface IAnalysisService
{
Task<AnalysisResult> AnalyzeAsync(
AnalysisRequest request,
CancellationToken cancellationToken);
}
AnalysisRequest里包含用户问题、数据库查询结果、业务字典、历史对话上下文;AnalysisResult里包含模型返回的文本、解析出的结构化结论、耗时和Token消耗统计。
接口简单,但实现里有几个细节值得说。
第一个是请求响应超时。生产环境里,7B模型在纯CPU机器上生成一段分析可能要30秒到2分钟,所以超时时间必须单独设置,不能跟着HttpClient默认的100秒走。我们实测下来,把超时设为180秒比较稳妥,但UI层要用独立的等待提示,不能让用户觉得程序死了。
第二个是上下文归一化。分析层交给AI适配层的数据可能是DataTable,也可能是从数据库拼出来的文本,适配层统一把它们序列化成紧凑的JSON或Markdown表格。这里有个性能点:如果数据量很大,比如一次查了5000条不良记录,全塞给模型会爆上下文窗口,所以分析层在拼接之前会先做采样和聚合,只把统计摘要传给模型。
第三个是流式和非流式两种模式。为了用户体验,我们做了流式返回——模型生成多少字,UI就显示多少字。适配层的StreamAsync方法把HTTP响应按块读出来,逐块触发UI更新事件。这个后面在实现部分详细展开。
2.3 为什么采用"本地部署 + 通用API"双通道
团队在模型部署方式上讨论了很多轮,最后拍板做双通道。本地部署用Ollama跑开源模型,通道路径走http://127.0.0.1:11434;通用API则保留OpenAI兼容格式的接入能力,配置里填baseUrl和apiKey就能切换。
本地部署解决的是数据隐私和离线可用问题。生产数据本来就敏感,很多厂区网络还做了隔离,不允许随便访问外网。Ollama部署在内网之后,所有请求都是局域网内部流量,数据不出机房。虽然没有外网模型那么强,但胜在可控、稳定、不依赖外部服务。实测下来,qwen2.5:7b-instruct模型在理解中文业务问题、生成结构化结论方面完全够用。
通用API通道是备胎,也是扩展点。如果某天业务方要求更强的推理能力,比如做复杂的多表关联分析,本地7B模型确实吃力,可以一键切到云端大模型通道。我留了一个约定:所有模型返回的文本格式,无论本地还是云端,都必须遵守同一份"输出规范",这样对上层业务透明。
双通道还有个好处是开发调试快。开发机上没有GPU时可以先接通用API调通功能逻辑,另一台测试机再用Ollama本地跑,两边同时推进,互不阻塞。
3. 从原始数据到Prompt:生产数据喂给大模型前的关键处理
3.1 数据清洗和列名统一,这一步决定AI回答质量的上限
有句话说得很对:Prompt写得好不好是下限,数据喂得好不好才是上限。最先动手的不是Prompt模板,而是把生产数据"收拾干净"。
车间数据库是多年攒下来的,字段命名很随意。产量表里日期字段叫ProDate,停机表里叫StopTime;不良类型有的写"划伤",有的写"scratch",还有的写"SC";单位也不一致,产量有按件算的,也有按箱算的。如果直接把这些数据丢给大模型,它很容易被脏数据带偏,答出来的结论自然也是错的。
我们做了一张"标准列名映射表",在分析层统一转换。这张表定义了几十个标准字段,比如:
| 标准字段 | 来源表 | 原字段 | 转换规则 |
|---|---|---|---|
| product_date | 产量表 | ProDate | 格式化为yyyy-MM-dd |
| line_code | 产量表 | LineID | A/B/C映射为A线/B线/C线 |
| output_qty | 产量表 | Qty | 按标准件换算 |
| defect_type | 不良表 | DefectCode | 映射为统一中文枚举 |
| stop_minutes | 停机表 | Downtime | 单位统一为分钟 |
| order_no | 工单表 | WoNo | 去掉前后空格和特殊符号 |
这一层还顺带处理了空值和异常值。比如停机时长出现负数,直接按0处理并在上下文里标注"该字段存在异常值,已做截断处理";产量波动超过正常范围的,不删数据,但会在摘要里标注"存在极端值"。让模型知道数据里有异常比让它蒙在鼓里好得多,否则它会一本正经地把脏数据当成结论依据。
3.2 构造数据字典与上下文包:把业务知识"喂"给模型前的最后一步
大模型不懂你的业务表结构,更不懂"OEE"是什么,"工单优先级"在业务上意味着什么。所以在拼Prompt之前,分析层会先构造一个"数据字典上下文",里面写清楚三样东西:一是表结构说明,二是字段业务含义,三是枚举值解释。
数据字典内容的样例如下:
text复制生产数据库包含5张核心表:
1. 产量表(product):记录每日各产线产量,字段有product_date、line_code、output_qty。
2. 不良记录表(defect):记录不良品明细,字段有product_date、line_code、defect_type、defect_qty。
defect_type枚举:划伤、色差、尺寸超差、装配不良、其他。
3. 设备停机表(downtime):记录设备停机事件,字段有product_date、line_code、stop_reason、stop_minutes。
stop_reason枚举:换型、保养、故障、缺料、待料。
4. 工单表(workorder):记录生产工单,字段有order_no、product_no、plan_qty、finish_qty、priority、status。
status枚举:未开始、进行中、已完成、已取消。
5. OEE表(oee):记录设备综合效率,字段有product_date、line_code、availability、performance、quality、oee_value。
这些内容看着枯燥,但对模型回答质量的影响是决定性的。没有数据字典的时候,模型会把"OEE"理解成"OE E"或者干脆编一个解释;有了字典,它至少知道OEE是设备综合效率,和可用率、性能、良品率这几个字段有关。
然后才是上下文包。一个完整的上下文包 = 系统提示词 + 数据字典 + 查询结果摘要 + 历史对话摘要 + 用户问题。系统提示词约定了分析角色、输出格式和禁忌;查询结果摘要是数据层查到的真实数据,格式化成一个紧凑表格;历史对话摘要只需要最近三轮,避免上下文无限膨胀。
3.3 让模型"只做分析,不做手脚":结构化输出约定
大模型最让人头疼的问题之一就是输出不可控。你问它"A线周二不良率多少",它可能先给你一段感慨,再在结尾给个数字,中间还夹着Markdown加粗。这种文本虽然在聊天场景里没问题,但程序里要自动解析就非常痛苦。
我们一开始就让模型严格输出JSON,并且给了一套明确的输出规范。系统提示词里写明:必须返回JSON对象,包含三个字段——answer(对用户的自然语言答复)、conclusion(数据分析结论,一句话)、metrics(涉及的关键指标,对象数组,每个指标含name和value)。同时规定:不允许输出JSON以外的任何文字,不允许使用Markdown代码块包裹JSON,如果不确定某个数值就写null,并在answer里明确说明。
实测下来,qwen2.5在7B参数级别下,只要Prompt规范给定得足够明确,绝大多数情况下能稳定输出严格的JSON,偶尔会有多余的前缀或后缀,配合解析层的"提取JSON"工具函数能兜住。温度参数设置也很关键,分析类任务我们固定用0.2,温度太高模型容易发挥,太低则过于机械。0.2这个值在"足够稳定"和"保留一点推理弹性"之间找到了平衡点。
还有一条很重要的约束:告诉模型"你只能基于提供的查询结果回答,如果查询结果不足以回答用户问题,明确说无法判断,禁止编造数据"。这条约束不能省,特别是在生产数据分析场景,一个编出来的"不良率5.3%"可能引发完全错误的生产决策。
4. 大模型调用与流式结果落地:核心实现与异常处理
4.1 异步调用的正确姿势:别让UI卡死
WinForms最容易被吐槽的点就是UI线程卡顿。如果在按钮点击事件里直接同步调用大模型接口,一个Streaming响应下来,整个窗体直接变白板,鼠标转圈,用户第一反应就是"程序死了"。
解决办法是async/await,但用的时候有几个坑。第一,事件处理器要声明成async void,不能用async Task,因为WinForms的事件委托签名要求返回void。第二,在async void里必须自己try-catch,否则异常会直接崩到Application级别的错误处理。第三,HttpClient实例最好是单例,不要在每次点击时新建,否则高并发下会耗尽socket端口。
下面是一段简化后的按钮点击代码:
csharp复制private async void btnAnalyze_Click(object sender, EventArgs e)
{
btnAnalyze.Enabled = false;
try
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(180));
var request = analysisService.BuildContext(txtQuestion.Text, dtPickerStart.Value, dtPickerEnd.Value);
var result = await analysisService.AnalyzeAsync(request, cts.Token);
txtResult.Text = result.Answer;
}
catch (OperationCanceledException)
{
MessageBox.Show("分析超时,请稍后重试或换个问法。");
}
catch (Exception ex)
{
Logger.Error(ex, "AI分析失败");
MessageBox.Show("分析失败:" + ex.Message);
}
finally
{
btnAnalyze.Enabled = true;
}
}
注意一个细节:await之后回到UI线程的代码,在没有ConfigureAwait(false)的情况下,会自动回到WinForms的SynchronizationContext,也就是UI线程,所以直接更新txtResult.Text是安全的。这也是WinForms写异步的一个"隐性福利",但在类库代码里记得要用ConfigureAwait(false),避免死锁。
4.2 流式返回:模型边生成边显示,用户就不用干等了
最开始做的是非流式,等模型全部生成完之后再一次性显示。问题是非流式在长回答场景下要等30秒到1分钟,用户看着转圈圈会忍不住乱点,体验很糟糕。
后来改成了流式。Ollama的/api/chat接口支持"stream": true,返回的是application/x-ndjson格式,每一行都是一个JSON对象。客户端用HttpClient的GetStreamAsync或者SendAsync配合ReadAsStringAsync逐行读取,把每次拿到的增量文本追加到界面上。
核心实现简化如下:
csharp复制private async Task StreamAnalyzeAsync(HttpClient httpClient, string endpoint, object payload, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line)) continue;
var chunk = JsonSerializer.Deserialize<OllamaChatChunk>(line);
if (!string.IsNullOrEmpty(chunk?.Message?.Content))
{
AppendToResultBox(chunk.Message.Content); // 回到UI线程后追加
}
}
}
UI线程的追加用BeginInvoke或直接依赖await回调,我倾向于后者,代码更直白。每次拿到增量文本,就把它追加到RichTextBox里。如果模型返回的是Markdown格式的段落,我们做了非常简化的处理:#开头的行加大字号加粗,表格行保持等宽,不做完整渲染。车间用户要的是"能看明白分析结论",不是学术论文排版,够用就好。
4.3 超时重试与降级策略:别让一次网络抖动毁掉一个工单分析
局域网内调Ollama,稳定性比公网好很多,但不是100%可靠。模型加载时间过长、显存不足、连接被占用,都可能导致请求失败。我们这边做了三层防护。
第一层是超时控制。单次请求超时180秒,超过就取消,前端提示"分析超时"。这里特别说明一下,180秒不是乱拍的,是实测出来的——7B量化模型在普通工控机的CPU上,生成长度1000字左右的分析回答,中位数在40秒左右,极端情况到120秒也很正常。如果设成60秒,会频繁误杀正常请求。
第二层是重试。重试只针对"可恢复"的失败,比如连接超时、HTTP 500、空响应这些;业务错误和参数错误不重试,重试只会浪费算力。重试采用指数退避,第一次等2秒,第二次等4秒,第三次等8秒,最多重试3次。
第三层是降级。如果本机Ollama连不上,看配置里有没有备用地址,有就切过去;如果备用地址也不可用,就从本地"常见问题缓存表"里找有没有相似问题,有就返回缓存结果并提示"结果来自历史缓存,可能不是最新数据";都没有就明确告诉用户"当前分析服务不可用"。降级的核心原则是:宁可给用户一个"弱答案",也不能让用户干等然后收到一个无法理解的异常堆栈。
5. 这些坑我替你们踩过了:WinForms + AI组合的实测排错记录
5.1 "一直转圈"背后的.NET异步死锁
上线测试第一天,就遇到了一个经典问题:点击分析按钮后,界面卡住转圈,等了很久都没有反应,但进程CPU占用率不低。一开始怀疑是模型调用慢,后来发现请求其实早就发出去了,但响应回来之后线程卡死了。
排查链路是这样的:先在按钮事件里断点,发现await analysisService.AnalyzeAsync(...)之后永远不回UI线程;再检查分析服务类库里的代码,发现内部所有await都没有加ConfigureAwait(false);再往深处挖,发现分析服务内部有一段同步阻塞调用——在async方法里用了.Result去等一个Task<bool>。
这是.NET Framework 4.7.2环境下最典型的async死锁模式:UI线程的SynchronizationContext把await回调排队到UI线程,但同时某处用.Result同步阻塞了UI线程,形成互相等待。
修复方法有三管齐下:一是所有内部await统一加ConfigureAwait(false),让异步代码不再尝试回到UI线程;二是严禁在async方法里使用.Result或.Wait(),必须用await从头到尾;三是UI层的事件处理函数保持async void,但内部所有库级别的调用都走async Task。改完之后,转圈问题再也没有复现。
这里也想提醒大家:如果你在做一个新项目,直接上.NET 6以上的版本,这个坑基本就没了;但如果像我们一样必须留在.NET Framework 4.7.2,那这条规则得写进团队代码规范里。
5.2 JSON解析为什么会炸:模型输出多余的Markdown代码块
流式显示没有问题了,但解析结果时经常报System.Text.Json.JsonException。打开日志一看,模型返回的内容里偶尔会带一个Markdown代码块包裹:
text复制```json
{
"answer": "...",
"conclusion": "...",
"metrics": [...]
}
```
问题根源在于模型在训练时被灌了大量的Markdown语料,遇到"请输出JSON"这种指令时,有些回答会顺手把JSON塞进代码块里,这本身对人类阅读很友好,但程序里的JsonSerializer.Deserialize不认识代码块。
如果只是偶尔出现,可以在解析之前做一层预处理:先用正则提取第一个{到最后一个}之间的内容,再去掉首尾空白,最后送到JSON解析器。这个"提取JSON"的兜底函数成了整个项目里最低调但最有用的工具:
csharp复制private static string ExtractJsonFromResponse(string response)
{
var trimmed = response.Trim();
if (trimmed.StartsWith("```"))
{
var firstBrace = trimmed.IndexOf('{');
var lastBrace = trimmed.LastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace)
{
return trimmed.Substring(firstBrace, lastBrace - firstBrace + 1);
}
}
return trimmed;
}
更彻底的办法是在系统提示词里明确写"禁止输出Markdown代码块,直接输出JSON对象",实测可以把出问题的比例从大概5%降到0.5%以内。但兜底函数还是要保留,这0.5%在用户面前就是100%的故障。
5.3 中文乱码:一个被忽略的Encoding问题
有段时间发现,部分机器的分析结果从第二个字开始全是锟斤拷,可以确定是编码问题,但奇怪的是同样版本程序,有一部分机器正常,有一部分乱码。
排查过程比想象中曲折。先检查请求,确认客户端发送的是UTF-8;再检查Ollama返回,用Postman手动调接口正常;最后发现是车间部分工控机的区域语言设置是"英语(美国)",导致StreamReader在读取HTTP响应时默认用系统区域对应的编码去解码,而不是文件头里的charset=utf-8。
解决方式很直白:所有读取流的地方,显式指定Encoding.UTF8,不依赖系统默认编码。类似的问题还发生在把中文写回数据库时,统一在连接字符串里加Character Set=utf8;,彻底根除。
这个坑让我养成一个习惯:凡是代码里出现字符串读取、编码转换的地方,一律显式声明编码,绝不交给运行时默认行为。
5.4 长文本截断导致的"一本正经胡说八道"
流式调用的响应虽然能一直接收,但模型本身有上下文窗口限制。qwen2.5:7b的上下文窗口是32K,听着很大,但一旦把数据字典、历史对话、查询结果都塞进去,很容易触顶。触顶后模型有两种表现:一种是突然截断,回答到一半就没有了;另一种更危险——它会把截断点之前的信息硬凑出一个完整结论,强行收尾,看起来像分析完了,实际上后半段是编的。
这个问题在生产环境尤其要命,因为生产数据分析经常需要看一周甚至一个月的数据,查询结果摘要很容易特别长。我们的应对思路分三层:
第一层,查询结果进入Prompt之前做"自适应摘要"。如果原始数据行数超过50行,先用Linq做聚合统计(按产线分组、按日期分组、算均值/合计),只把聚合结果和Top5异常明细放进上下文,而不是全量塞进去。
第二层,历史对话只保留最近3轮的摘要,更早的对话通过一个SummarizeAsync方法压缩成一句主题描述,比如"用户上周问过A线不良率趋势,结论是划伤占比升高"。
第三层,在UI上做长度警告:如果模型生成的内容超过一定长度,提示用户"回答可能因长度限制不完整"。同时在代码里检查返回JSON是否完整,metrics数组缺失或conclusion字段为空时,视为生成失败,触发重试或降级。
6. 性能、成本与数据安全的平衡:上线前必须做的几件事
6.1 缓存与复用:同一个问题不要调两次模型
大模型推理成本虽然比几年前低很多,但本地部署也是要占用算力的,而且每次调用都让车间工控机的CPU飙升,风扇声大得吓人。实际上生产场景里,用户问的问题重复度相当高——"今天A线不良率""本周各线产量对比""上月停机原因分析",这些问题换个日期就是新问题,但本质上高度相似。
我们做了一张analysis_cache表,字段包括question_hash、question_similar、answer_json、created_at、expire_at。默认缓存有效期是2小时。这样同一个问题在2小时内再次被问到,直接命中缓存,不用动模型。
更近一步是"语义缓存"。哈希匹配太严格,用户问"今天A线不良率"和"A线今天不良率",哈希完全不同,但业务含义一样。我们做法是先用一个轻量的TF-IDF向量把问题转成特征向量,再用余弦相似度和最近24小时内的历史问题做比对,相似度超过0.9就直接复用历史答案,并在UI上标注"来自近似历史问题"。这个词嵌入计算量远小于大模型推理,完全可以在客户端本地完成。
实测下来,缓存命中率在30%左右,虽然不是特别高,但已经明显减轻了模型服务压力,而且用户反馈"常见问题秒回",体验反而更好了。
6.2 数据脱敏与权限控制:AI能查的表不等于所有人都能看
生产数据里有很多内容不适合让一线操作工看到,比如工单的实际成本、设备采购价格、员工绩效数据。如果AI助手对所有账号一视同仁,一定会出合规问题。
所以我们把"AI能查到什么"和"用户权限能看什么"绑在一起。具体实现是:在分析服务入参里带上userId和role,分析层构造数据字典时,根据权限过滤掉不可见字段;系统提示词里也加一条"用户权限为X,只可查询Y类数据"。数据层查询SQL会根据角色自动附加行级过滤条件,比如普通操作工只能看自己产线的数据,班组长能看整个车间,工程师能看全部产线。
除此之外还做了字段级脱敏配置。比如工单表里的operator_name,对普通用户显示为"操作工A",对管理员显示全名;成本字段unit_cost对非财务角色直接不查询。模型输出本身也可能包含脱敏前的数据,所以AI适配层在返回结果前会再跑一遍脱敏正则,把手机号、工号、身份证号等敏感信息替换掉。
最后一个必须做的是审计日志。每次AI分析请求,都记录用户ID、问题原文、查询了哪些表、模型返回了什么、耗时多少。这不仅是合规要求,也是排查问题的重要依据——如果某个用户问出了越权数据,日志里一查就知道是从哪一步漏出去的。
6.3 实测效果与量化指标:别光吹"AI很聪明",要看数据
上线前我们做了两轮效果评估,一轮是拿真实历史问题做回归测试,一轮是在车间现场让3位分析员试用两周。
回归测试的样本是从过去一年的工单和报表里整理出来的50个典型问题,覆盖"查询类""对比类""诊断类""建议类"四种。评分标准是:回答中的关键数值与真实报表一致的记为"完全正确";数值正确但结论表述不完整的记为"基本正确";数值错误或编造数据的记为"错误"。7B模型在50个问题上的分布是:完全正确33个,基本正确12个,错误5个。错误主要集中在"多表关联的复杂计算",比如"计算A线3月份每条工单的良率并把最低的三条列出来",模型容易在关联逻辑上犯错。
现场试用反馈最集中的意见是:用户希望AI不只给文字结论,还要给可复制的数据表格。所以我们后来把metrics字段的展示做成了DataGridView,模型返回的指标自动填充到表格里,用户可以一键导出到Excel,这个功能在车间很受欢迎。
响应时间方面,非流式完全生成的平均耗时约46秒,流式模式下用户能看到逐字输出,感知等待时间大幅缩短,普遍反馈"可以接受"。由于结果是JSON结构化返回,answer文本只用于自然语言阅读,不需要二次编辑,整体流程已经很顺。
关于准确性,我的态度是:任何AI分析结果都必须带上"AI生成内容,仅供参考,最终以人工复核为准"的提示。这不是免责声明,而是对用户负责。生产数据直接影响决策,模型再聪明也可能出错,一定要在UI上给用户一个"可验证"的按钮——点击后展示这条结论涉及的原始查询SQL和关键数据源,让人能自己核对。
6.4 后续可以扩展的三个方向
项目上线稳定运行之后,手上还有几个想法没有全做进去,留给后续迭代。
一个是"自动生成分析报告"。现在用户是在对话框里问问题,但很多分析场景其实是固定周期的,比如每天早会前要一份前一日产量和质量摘要。可以做一个定时任务,每天早上6点调用模型生成当日摘要报告,推送到客户端通知栏,减少人工翻报表的时间。
再一个是"语音交互"。车间操作工戴着手套的时候敲键盘很不方便,如果能在WinForms里接入本地语音识别,让用户直接说"帮我查一下A线今天产量",体验会上一个台阶。Windows自带的System.Speech或者第三方的离线语音识别包都可以试试。
还有一个是"异常自动预警"。模型虽然能回答问题,但问题得人来问。如果给模型再接一层自动监控——定期扫描不良率、OEE、停机时长等核心指标,发现连续异常时主动推送一条"疑似异常"的分析提示,就从一个被动问答工具变成了主动的产线助手。这一步涉及的技术不多,主要是把现有的查询和Prompt管线接上定时器,但业务价值提升是明显的。
写到这里,我想起每次给用户演示这个WinForms项目时,大家最惊讶的都是"老桌面程序也能塞进AI"。其实技术本身并不神秘,关键是把数据分析、数据字典、Prompt工程、异步调用这些环节都打磨得顺手。如果这个项目笔记能给你在做类似功能时提供一点参考,或者帮你少踩几个我踩过的坑,那就很值了。
最后再分享一个小习惯:上线后我坚持每周导出一份真实的用户提问日志,粗筛一遍看大家在问什么、哪些问题经常让模型"答非所问"。这些日志比任何评测集都更真实,是持续优化Prompt的最好素材。毕竟AI助手这东西,只有天天用、天天看反馈,才会越用越顺手。
