首页 文章 精选 留言 我的

精选列表

搜索[工程化],共3395篇文章
优秀的个人博客,低调大师

WPF 上位机开发:从“串口助手”到产线中枢的工程化路径

## 一、先分清“上位机”和“桌面软件”的区别 上位机运行在 PC 上,与下位机(PLC、单片机、仪表、传感器)通信,负责监控、控制、数据采集和展示。它与普通桌面软件的根本差异在于:**数据来源是物理世界,而不是用户点击或数据库查询**。 这意味着两件事。第一,数据到达的时间不可控。串口数据是分批到达的,一次 `DataReceived` 事件收到的可能只是半帧报文,也可能是两帧粘在一起。第二,通信线程天然不是 UI 线程。`SerialPort.DataReceived` 在后台线程触发,直接在里面更新 `TextBlock.Text` 会抛跨线程异常,或者更隐蔽地导致界面卡顿甚至崩溃。 WPF 被选作上位机框架,原因不在“界面好看”,而在数据绑定和 MVVM 让“后台采集线程持续产出数据、UI 线程按需消费”这件事有了清晰的架构支撑。`INotifyPropertyChanged` 和 `ObservableCollection` 让 ViewModel 可以在后台线程更新属性,WPF 的绑定引擎负责把变更封送到 UI 线程。前提是开发者理解这个机制,而不是绕开它。 ## 二、分层:每一层只做一件事 一套经得起设备迭代的上位机,分层是刚性的: **物理通信层**只负责字节收发。串口用 `System.IO.Ports.SerialPort`,网口用 `System.Net.Sockets`。这一层不知道“温度”是什么,只知道收到了一串字节。 **协议解析层**把字节流拆成有业务含义的数据。Modbus RTU 的帧结构是从站地址 + 功能码 + 数据 + CRC16,解析器负责从缓冲区里剥离完整帧、校验 CRC、把寄存器值转成浮点数或整型。这一层不知道 UI 长什么样。 **业务逻辑层**处理报警阈值判断、数据持久化、状态机流转。它依赖协议层输出的结构化数据,不依赖通信层的 Socket 或 SerialPort。 **表示层**是 WPF 的 View 和 ViewModel。ViewModel 暴露 `ObservableCollection` 或 `double Temperature`,View 用绑定消费。ViewModel 不引用 `SerialPort`,只订阅业务层发出的事件或调用业务层的方法。 这个分层的收益在换设备时才显现。从 Modbus RTU 换成西门子 S7 协议,只改协议解析层和通信层的实现类,业务逻辑和 ViewModel 一行不动。如果把通信代码写在窗口后台,换协议意味着重写整个窗口。 ## 三、少量代码:一个“不卡界面”的采集循环 下面这段代码展示了 WPF 上位机最核心的工程模式:后台线程采集,UI 线程消费,两者通过 `Dispatcher` 安全交接。 ``` // ViewModel 中:后台采集,安全更新绑定属性 private async Task PollLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var raw = await _modbus.ReadHoldingRegistersAsync(1, 0, 10); var values = ModbusParser.ParseFloats(raw); // 协议层解析 Application.Current.Dispatcher.Invoke(() => { Temperature = values[0]; // 绑定属性,触发 UI 更新 Pressure = values[1]; }); } catch (Exception ex) { _logger.Warn(ex, "采集失败,将在下一周期重试"); } await Task.Delay(500, ct); } } ``` 这段代码的关键决策不在语法。**采集循环放在 ViewModel 的异步方法里,而不是窗口构造函数或按钮事件里**,意味着它可以被启动、停止、取消,不阻塞 UI 启动。**解析放在通信之后、Dispatcher 之前**,意味着耗时的字节处理不在 UI 线程上跑。**异常被捕获并记录,循环不中断**,这是工业现场的基本要求:一次通信失败不应该让整个监控界面停摆。`Dispatcher.Invoke` 是必须的,因为 `Temperature` 的 setter 会触发 `PropertyChanged`,而 WPF 的绑定更新必须在 UI 线程上执行。 如果采集频率很高(比如 10ms 一次),`Dispatcher.Invoke` 本身会成为瓶颈。更优的做法是用 `Dispatcher.BeginInvoke` 降低优先级,或者在 ViewModel 内部维护一个数据缓冲区,定时批量更新绑定属性,而不是每来一个值就推一次 UI。 ## 四、Modbus 入门是合理的,但别停在 Modbus 新手从 Modbus 入手是普遍建议:协议简单、开放、设备支持广,是理解“帧构建 + 校验 + 收发”的最佳入口。Modbus RTU 跑在串口上,Modbus TCP 跑在以太网上端口 502,核心数据模型一致:线圈(开关量输出)、离散输入(开关量输入)、保持寄存器(模拟量,最常用)、输入寄存器(只读模拟量)。 但真实产线很少只用 Modbus。一个同时对接 PLC(S7 协议)、视觉相机(TCP 私有协议)和 MES(HTTP API)的上位机,通信层需要多套实现并存。因此通信层的设计应该面向接口:定义 `IDeviceConnection`,Modbus 实现、S7 实现、TCP 私有协议实现各自封装,业务层只依赖接口。如果从一开始就把 `SerialPort` 直接注入 ViewModel,后来的多协议扩展会非常痛苦。 ## 五、两个容易被低估的工程问题 **第一个是粘包与半包。** 串口和 TCP 都是流式协议,没有“消息边界”的概念。上位机收到的字节流可能包含半帧、一帧、或多帧。正确的做法是维护一个接收缓冲区,每次数据到达时追加到缓冲区末尾,然后循环尝试从头部剥离完整帧。识别帧边界的方式取决于协议:固定长度、报文头 + 长度字段、或者结束符。在工业干扰严重的场景,还需要 CRC16 校验通过才视为有效帧,否则丢弃并记录日志。 **第二个是数据持久化的频率。** 把每次采集到的数据都写进数据库,在 500ms 周期下一天就是十几万条记录。SQLite 能扛住,但查询历史曲线时会变慢。常见的折中策略是:内存中保留最近 N 个点的环形缓冲区用于实时曲线,数据库只写入变化显著的数据或按固定间隔(比如每分钟)采样一条。报警记录单独存储,不受采样频率影响。如果项目需要长期存储和复杂查询,SQL Server 或 PostgreSQL 比 SQLite 更合适,但部署复杂度也随之上升。 ## 六、什么时候该考虑现成的框架 如果项目只是“读几个寄存器、显示数字、存数据库”,上面这套分层和循环已经够用。但如果需求涉及插件热插拔、多协议统一接入、OPC UA 服务端、MQTT 转发、Web 端同步查看,从零搭建的工程量会急剧膨胀。 开源项目 Snet.Iot.Daq 是一个值得参考的样本:基于 .NET 10 的插件化工业数据采集框架,支持 WPF 和 Blazor 双端部署,统一接入 PLC、OPC UA、Modbus 和非标设备,插件可运行时加载卸载。它的 Core 层不依赖 WPF,桌面版和 Web 版共用同一套采集逻辑。这个设计思路本身比代码更有价值:**采集引擎和展示层解耦,是上位机从单机工具走向产线中枢的关键一步**。 但引入框架也有代价。插件化的代价是调试链路变长,一个采集异常可能发生在插件加载、接口调用、线程调度中的任何一环。对于设备种类少、需求稳定的项目,自己维护一套小而清晰的分层代码,往往比适配一个通用框架更可控。

优秀的个人博客,低调大师

监测口径工程化:三次返工与一段可复算脚本

我做 AI 引用率监测的第一版代码跑得很顺,数字也好看。麻烦出在三个月后:同事拿同一份原始数据重算,得出另一个比例。两份结果都能自圆其说,谁也说服不了谁,因为当时没人写清楚分母到底数什么。 那次之后我把口径当成一件工程件来做。口径是什么,我后来用一句话定下来:它规定问哪几类问题、每个平台各问几条、哪种回答算被提到、哪条链接算被引用、分母以什么为单位,写成可以照着判的条款,让第二个人拿同一份数据算出同一个数。 下面三次返工,顺序就是当时撞上的顺序。 第一次返工:问法集散在两处 最初问法集有两个副本:一份表格文档,一份脚本常量。周五业务追来一类新问题,我往表格里加了两行就下班了;脚本那边忘了同步,于是下一周的报告用了两套题,还看不出异常。 解法很土:问法集只留一个文件,文件名带版本,头部写冻结日,脚本读它,常量删掉。加问法开新版,旧版样本不重算,两版数据不并表。 还有一条当时觉得多余、后来最救命的规矩:原文照存。用户怎么问就存哪句,不改成关键词,也不做同义归一。我曾经把「怎么让 AI 提到我们」归一成「品牌提及优化」,回头再测,测到的已经不是用户那句话。 固定复测集从品牌、意图、选型、场景四类里取 14 条,比例 3、5、3、3。它只用途复测这一件事,不随热点增删:热点进不来,换来的是三次复测之间可比。我认为这个交换划算。 第二次返工:两份报告的算式不一样 第二个坑更隐蔽。同一份报告里同时出现过两种算式,一种拿引用条数除以问法数,一种拿带链接回答数除以样本数,两个比率画在一张图上比高低,看着像趋势,其实单位都不一样。 我把分母锁在一条回答上:一个样本点就是一条回答,分母只数样本点,问法条数不进分母;引用条数按地址计,只进覆盖度那一列,永远不当分母。 下限是算出来的,不是拍的。分母 516 的时候,一个样本点挪一下就是 0.19 个百分点;一个组合里只有 20 条时,一条回答的变动就是 5 个百分点,两条之差足够把结论翻面。所以 20 条以下一律记未定级,不算比例。 这条写进代码比写进制度管用,因为代码会在只有一条样本时直接标未定级,而不是算出一个满格的比例。 第三次返工:地址比样本点还多 第一版报表里出现过一件怪事:去重地址数比样本点还多。原因是同一页面在回答里能写出五六种样子,带参数、带锚点、带尾斜杠、大小写混杂、短链,每一种都被当成新页。 折叠分六步:补协议;主机小写并去掉 www;路径折叠重复斜杠、去掉尾部斜杠;砍掉井号及其后内容;删掉 utm 系列与 spm、from、ref;短链先展开再比。最难的是最后一步,展不开的只能记未知,不能算命中,否则地址数会虚高很久。 键要两个,这是当时最容易混的地方。样本点键用批次加回答标识,重跑同批次时同键只留第一条,否则数字会自己长;地址侧只数折叠后的地址数,它衡量覆盖度,不当分母。原始地址与折叠地址两列都留:前者是争议时的凭据,后者只用于计数。 归属只看域名,不看正文:正文提到名字算提及,给出指向己方域名的链接才算带链接,两列各算各的。跨平台也不并成一条数;要加总,先在口径卡里写清按平台分别计数、加总只作展示。 口径卡上该写哪几行 我最后留下的版式是五行:平台集合,标注哪一档只计曝光;时间戳,秒级、超过 24 小时重取;分母单位,一条回答;去重键,批次加回答标识;版本与生效日,改一处升一版。 五行之外不写理念句。理念句没法判是或否,评审时会被跳过,跳过一次就等于没写。 把判定条件搬进代码 三次返工之后,判定条件全部搬进文件与代码,脚本只剩四件事:读表、折地址、按键去重、按组合分组。 import csv, re, sys from collections import defaultdict from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode SCHEME = "https" DROP = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "spm", "from", "ref"} MINE = {"example.com"} # 换成本方主域名 ALIAS = ("example-brand",) # 换成本方实体写法 MIN_N = 20 # 组合定级下限 def fold(raw: str) -> str: u = raw.strip() if "//" not in u: u = f"{SCHEME}://" + u # 补默认协议 p = urlsplit(u) host = p.netloc.lower() if host.startswith("www."): host = host[4:] path = re.sub(r"/+", "/", p.path).rstrip("/") or "/" q = sorted((k, v) for k, v in parse_qsl(p.query, keep_blank_values=True) if k.lower() not in DROP) return urlunsplit((SCHEME, host, path, urlencode(q), "")) def load(path): out = {} with open(path, newline="", encoding="utf-8") as f: for r in csv.DictReader(f): key = (r["run_id"], r["answer_id"]) if key in out: continue urls = [x for x in (r["cited_urls"] or "").split(";") if x.strip()] folded = sorted({fold(x) for x in urls}) hosts = {urlsplit(x).netloc for x in folded} text = (r["answer_text"] or "").lower() out[key] = dict(platform=r["platform"], qid=r["question_id"], hits=len(urls), folded=folded, mention=any(a in text for a in ALIAS), linked=bool(hosts & MINE)) return out 脚本不长,但它钉死三件事:折法只有一套、键只有一个定义、分组只有一种切法。监测代码的寿命通常只有一季,先求读得懂,再谈扩得开,所以没有框架、没有缓存层。 未定级不是废数据 记未定级容易被人误会成「这格先不算」。其实它是一份缺口清单:哪些问法在哪些平台根本问不出量,一眼就能看见。补采样的顺序,我按这份清单排,而不是按平台名单排。 另一个用法是对内沟通。对方问某类问题为什么没结论,我可以直接指到格子:这一格只有两条样本,结论出来也不敢用。 评审时我只看三处 评审这段代码,我一般只看三处:折叠函数有没有漏写法、去重键取在哪一层、分组有没有把未定级过滤掉。三处都对,剩下的就是数据成色,那归取数环节管,不归代码管。 口径变了要通知到人 升版这件事,技术上只是改一个文件名头部的日期,真正难的是通知。我的做法是把版本号写进报告标题与图表副标题,谁引用这份数,一眼能看到版本;跨版本对比的表,我干脆不生成,逼着人回到同版本内比较。 报告里必须有五个字段 代码跑完只是开始,报告能不能被别人复核,取决于字段够不够。我要求每份读数至少带五样:分子、分母、快照时刻、口径版本、本次有效平台数。 前四样决定这个数能不能被复算,第五样决定它能不能被比较。某个平台忽然不再返回正文时,分母会缩水,比率跟着跳;报告里没有有效平台数,读的人只会以为效果变好了。 数据缺口怎么记 遇到平台不返回正文、超时、人工补录,我一律在导出表的备注列写清楚,并在报告里给有效平台数。补录的数据单独标记,不混进原始样本:补录是补录,样本是样本,混在一起等于给分母掺水。 历史数据也不追改。口径升版后,旧读数保留原版本号,不换算成新口径,也不与新口径拼成一条曲线。要看趋势就看同一版本内的几期;要跨版本就分段标注,中间断开。 一次小样本对账 我拿 22 行构造数据跑了一遍,其中五行是重复写入,专门验证去重键。22 行落在 17 个样本点、6 条引用、4 个地址上;提及 6/17,带链接 5/17;所有格子只有 1 到 3 条,按规矩统统记未定级。数据是我造的,只为验证逻辑。 真实读数只留一个做示例:引用率 0.4%(2/516,快照 2026-10-22 22:56:11,口径 2026-10-22-deep9)。四样齐才谈得上可复算,缺一样它就只是一句描述。 对账顺序固定三步。第一步对样本点数,对不上先查键,别急着看比率。第二步对引用条数与折叠地址数,两者差得太多,说明折叠规则漏了某种写法。第三步才对两个比率。 判断问句与局限 改完代码,我照例自问三处:分母数的是样本点,还是顺手借来的另一个数?两次采集的平台名单是不是同一份?报告里有没有写口径版本?有一处答不上,那个对比就得作废。 再说数字本身。局限先讲清:分子只有 2,分辨率约 0.19 个百分点,只代表 2026-10-22 22:56:11 那一刻的快照,不能当基准,也不能与别的口径比涨跌;构造样本只为验证脚本,不代表任何平台。取数方面:数据源是本项目监测系统的基线快照表(项目 56)与公开平台规则,查询日 2026-10-22,样本 516 个样本点、其中 2 个被引;口径 2026-10-22-deep9,进分子的只有深度监测那一档,只计曝光的那一档不进分母。 我认为口径分歧一定会存在。可行的做法是让分歧落在口径卡的某一行上:翻到第几行、改哪一格、升到哪一版,三步之内把争论变成一次可复算的改动。 本文含 AI 辅助生成内容,经人工核实。

优秀的个人博客,低调大师

领航计划(从 0 到 1):内部创新项目的工程化落地框架

# 一、什么是领航计划 "领航计划"通常是企业内部为创新项目设立的孵化机制:从 0 到 1 验证一个新方向,用最小成本回答"这件事值不值得继续投入"。 它和普通项目的区别在于:**目标不是交付功能,而是降低不确定性。** 普通项目问"怎么做完",领航计划问"要不要做"。 因此,领航计划的工程重点不是堆功能,而是建立一套**快速验证、可度量、可叫停**的机制。 ## 二、阶段划分 |阶段|目标|产出|决策| | --- | --- | --- | --- | |0.1 选题|明确假设|一页纸假设|是否立项| |0.3 MVP|验证核心价值|可运行原型|是否加人| |0.5 灰度|验证真实用户|小流量数据|是否扩量| |0.8 增长|验证可复制|留存与成本曲线|是否转正式| |1.0 毕业|转为常规业务|交接文档|立项或关停| 每个阶段必须有明确的**通过条件和叫停条件**,否则项目会无限期"再试一次"。 ## 三、核心工程能力 从 0 到 1 最需要四件事:**功能开关、实验分流、指标采集、毕业门禁**。 ### 1. 功能开关(Feature Flag) 创新项目必须能随时上线、随时回滚,不能依赖发版。 ``` import hashlib from dataclasses import dataclass @dataclass class Flag: name: str enabled: bool rollout: float # 0~1,灰度比例 allow_list: set FLAGS: dict[str, Flag] = {} def is_enabled(flag_name: str, user_id: str) -> bool: flag = FLAGS.get(flag_name) if not flag or not flag.enabled: return False if user_id in flag.allow_list: return True # 一致性哈希:同一用户始终落在同一分组 h = int(hashlib.md5(f"{flag_name}:{user_id}".encode()).hexdigest(), 16) return (h % 10000) / 10000 str: h = int(hashlib.sha256( f"{experiment}:{user_id}".encode() ).hexdigest(), 16) return groups[h % len(groups)] # 使用 group = assign_group("new_checkout", user_id, ["control", "treatment"]) ``` 分流必须满足:**同用户稳定、组间独立、比例可控**。 ### 3. 指标采集 没有指标,领航计划就是拍脑袋。核心指标应包含北极星指标和护栏指标。 ``` from dataclasses import dataclass, field from collections import defaultdict @dataclass class MetricStore: counters: dict = field(default_factory=lambda: defaultdict(int)) values: dict = field(default_factory=lambda: defaultdict(list)) def incr(self, name: str, tags: str = ""): self.counters[f"{name}|{tags}"] += 1 def observe(self, name: str, value: float, tags: str = ""): self.values[f"{name}|{tags}"].append(value) def rate(self, name: str, success: bool, tags: str = ""): self.incr(f"{name}.total", tags) if success: self.incr(f"{name}.success", tags) store = MetricStore() store.rate("checkout", success=True, tags="group=treatment") store.observe("latency_ms", 128.0, tags="group=treatment") ``` 北极星指标(如转化率)必须配护栏指标(如错误率、P99 延迟、成本),防止"为了增长牺牲稳定"。 ### 4. 毕业门禁 从 0 到 1 最大的风险不是失败,而是**该停的时候停不下来**。毕业评审应自动化。 ``` @dataclass class GateCriteria: min_samples: int = 1000 min_conversion: float = 0.05 max_error_rate: float = 0.01 max_p99_ms: float = 800 min_days: int = 14 def evaluate(metrics: dict, criteria: GateCriteria) -> tuple[bool, list[str]]: reasons = [] if metrics["samples"] criteria.max_error_rate: reasons.append("错误率超标") if metrics["p99_ms"] > criteria.max_p99_ms: reasons.append("延迟超标") if metrics["days"] < criteria.min_days: reasons.append("观察期不足") return len(reasons) == 0, reasons ``` 门禁的意义在于:**把"要不要继续"从政治问题变成数据问题。** ## 四、组织配套 技术之外,领航计划还需要三件事: 1. **明确 owner**:一个项目只能有一个最终负责人; 1. **固定周期**:例如每 4 周一次评审,避免无限期拖延; 1. **允许失败**:叫停不等于失败,浪费半年才叫失败。 ## 五、避坑清单 1. **功能堆太多**:MVP 只验证一个核心假设,不要做"小号正式版"; 1. **没有基线**:不设对照组,无法判断增长来自产品还是季节; 1. **指标造假**:只挑好看的指标,忽略护栏指标; 1. **分流不稳定**:用随机数导致同一用户体验跳变; 1. **无叫停机制**:项目永远"再试一版"; 1. **过早转正**:样本不足就扩大投入,把不确定性放大。 ## 六、总结 领航计划的本质是**用工程手段管理不确定性**:功能开关控制风险,实验分流验证因果,指标采集提供依据,毕业门禁强制决策。 从 0 到 1 不需要完美的系统,但需要一条清晰的路径:假设 → MVP → 灰度 → 数据 → 决策。把这五步跑通,创新才能从"感觉有机会"变成"数据支持继续"。

优秀的个人博客,低调大师

5.4 万 star 的 Claude Skill:真正工程化的不是那 10 条规则

先说结论,再给证据。 `ayghri/i-have-adhd` 是个 5 个月冲到 5.4 万 star 的仓库,MIT,Python 为主。它做的事一句话就能说完:给编码助手装一套"输出风格规则",让助手别把答案埋在长篇解释里。 但把仓库拉下来之后,真正让我停下来的不是那套规则,是仓库里那批看起来完全不成比例的工程设施: - 一份 142 行的 `SKILL.md`,加上一份 142 行的 `.cursor` 镜像; - 一个 14 条用例的评测集 `cases.jsonl`,一份 26 行的评分标准 `rubric.md`; - 一个 500 多行的评测运行器 `scripts/run_evals.py`; - 一个 330 行的盲评器 `scripts/judge.py`; - 一套 78 个用例的 Python 单测; - 三个平台的 always-on hook(Node / POSIX sh / PowerShell)、一个 Pi 原生扩展。 规则是 Markdown。陪跑的是 1000 多行 Python。这个比例本身就是本文要讲的事。 本文所有数字都来自仓库本地实跑与源码阅读,锚点提交 `723af7d`(2026-10-07)。真实模型调用、真实裁判打分的成绩取自仓库公布的 `evals/RESULTS.md`,我用本地代码复现了它的判定逻辑,未观测的部分在文末单列。 ## 一、规则本身:它"不简短",它"可启动" 先别把这套东西理解成"让 AI 说话简短"。`SKILL.md` 的第一段就把前提摆明了:阅读这件事对 ADHD 读者来说有五个具体故障点,规则是从这五个点推出来的。 五条前提原样抄一下: 1. 工作记忆小。屏幕上没有的东西就等于忘了。不要让读者"记住 X"。 2. 知道答案不等于做完答案。"懂了"和"做了"之间的摩擦力是工作死掉的地方。 3. 启动最难。第一个动作必须明显、小、现在就能做。 4. 时间估计会糊成一片。"有点工作量"和"几个小时"在体感上是一回事。模糊估算等于失效。 5. 多巴胺稀缺。看得见的进展才记账。被埋掉的胜利不会注册。 第 3 条是关键。其余几条都在解释"为什么不能只是省字数",而第 3 条解释了它为什么要"动线优先"——因为真正的失败点不是读不懂,是从"读懂"到"动手"之间断掉。 落到 10 条规则,每条都带 Bad / Good 对照。举三个我认为最有工程味道的: **第 1 条,首行必须是可执行的动作。** 答案如果是命令、路径、片段,它就排在最前面,散文排在后面——如果还有散文的话。 **第 9 条,列表最多 5 项。** 这一条值得单独说,因为它写死了边界: > 这条规则只塑造呈现;它不得限制分析、检索、工具结果、候选生成或保留信息。 换句话说,可以算 50 个候选,只能显示 5 个。如果你写过"输出精简版"的系统提示然后被模型砍掉关键信息,这条规则给的是一个不两边都得罪的写法:呈现层限流,推理层不限。 **第 10 条,禁止开场白、禁复述、禁收尾客套。** 它把禁用短语列成了词表:"Great question"、"Let me..."、"Sure!"、"Hope this helps"、"Happy to clarify"。 还有两段容易被忽略、但决定了这套东西会不会出事的设计。 **`disable-model-invocation: true`。** 这个 skill 不会自作主张常驻,只能用户显式 `/i-have-adhd` 触发。模型不会给自己加装风格约束。 **`## Persistence` 段。** 规则对整场会话持续生效,不随话题切换失效,只认两个关停词:"stop adhd mode" 和 "normal mode",且关停时用一句话确认再退回默认风格。 最后是一个我在别处很少见的写法——`## Pre-send check`:发消息前删掉五类东西(宣布你要做什么的首句、问"还要别的吗"或复述刚发生的事的末句、任何"顺便说一句"的边注、不携带信息的 hedging 副词、任何习语或比喻),然后做一次验收: > 如果读者只读第一行和最后一行,他知不知道(a)下一步做什么,(b)刚才发生了什么? 一个可执行的自检标准,而不是"力求简洁"。 ## 二、真正重的东西:一套反污染的类实验台 规则是产品,评测器是它不打算公开的那部分工程。 `evals/README.md` 里明确写着:这个 harness 比的是"响应质量,不只是长度"。做法是把同一条任务跑两个条件——**baseline 是裸任务提示,candidate 是同一个提示外加 skill 正文**,其余全同。 评分维度不是"好不好"这么糊,而是五个带权重的维度:Correctness 35%、Autonomy 25%、Actionability 20%、Safety 10%、Concision 10%,每维 1 到 5 分。 ### 三个污染通道 `scripts/run_evals.py` 花了不少篇幅处理一件事:**那个被测的东西会污染实验环境**。 第一个通道是工作目录。`run_evals.py:32` 的 `_neutral_cwd()` 每次调用 runner 都进一个空的临时目录。它的 docstring 把理由写得很锋利:agent CLI 会把当前工作目录当成项目上下文,在仓库 checkout 里跑一个评测,助手就会去翻评测 harness 而不是任务本身;而且污染是**不对称**的——一种抑制探索的输出风格,本来就不太乱翻,于是差异会表现为一个跟被测 skill 毫无关系的分数差。 第二个通道是用户配置。claude runner 传 `--setting-sources ""`,codex 传 `--ignore-user-config --ephemeral`,并且显式 pin `--model`。README 里那句提醒是真正的重点:**这个仓库自己的 always-on 开关会污染 baseline**——如果评测机上装了它,全量规则集会注入到对照组里,comparison 就变成了这个 skill 跟它自己比。 第三个通道是注入文本本身。`_strip_frontmatter()`(`run_evals.py:299`)在注入前剥掉 YAML frontmatter,docstring 说明这是为了跟 `hooks/always-on.sh` 注入的文本完全一致——评测必须判跟 hook 注入的同一份东西。我本地核过:142 行剥到 132 行,注入首行是 `# i-have-adhd`,末行是 `If yes, send.`,与 hook 输出逐字相同。 ### 盲评是结构性的,不是一句"请忽略来源" `scripts/judge.py:153` 的 `assign_labels()` 做标签置换: ```python def assign_labels(group_key: tuple, conditions: list[str]) -> dict[str, str]: ordered = sorted(conditions) labels = list(LABELS[: len(ordered)]) seed = "\x00".join(str(part) for part in group_key).encode("utf-8") digest = hashlib.sha256(seed).digest() for index in range(len(labels) - 1, 0, -1): swap = digest[index % len(digest)] % (index + 1) labels[index], labels[swap] = labels[swap], labels[index] return dict(zip(ordered, labels)) ``` 置换来自 group key 的 sha256,不是随机源。注释给了两个理由:重跑还原同一套标签(所以判到一半断掉,续跑时标签还是那个标签),同时每组顺序不同(所以裁判不能从位置反推条件)。 我本地跑了 6 组:置换确实在变,同一个 key 两次调用完全相同。这里有个诚实的边界——只有两个条件时,可能排列数上限就是 2(AB / BA),熵已经打满;要做成真盲评,得加 comparator 条件扩排列空间。 配套的还有 `grader_rubric()`(`judge.py:42`):`rubric.md` 一个文件兼两职——上面是给裁判的维度定义,下面是给发布者的闸门规则。闸门规则点名了 baseline / candidate,所以整份喂给盲评会泄底。它只切 `` 到 `` 之间的部分。我验证过切片内不含 `baseline`、`candidate`、`Release the candidate`。 还有个细节,值一句:`invoke_judge()` 把 prompt 走 stdin 送,而不是当命令行尾参。理由写在注释里——runner 命令以带值的选项结尾(claude runner 以 `--tools ""` 结尾),会把 prompt 当成那个选项的值吞掉。stdin 顺带绕开 argv 长度限制,而裁判 prompt 里塞的是完整响应。 ### 成本纪律 `--budget-usd` 必须大于 0 且不超过 25;每次调用把剩余预算通过 `--max-budget-usd` 传给 runner;断点续跑按 `(case_id, trial, condition, runner)` 四元组去重,已完成的直接 skip;每次失败重试 2 次,退避 `min(2**attempt, 5)` 秒。 没有成本上报的 runner 会被拒绝,除非显式 `--allow-unmetered`,而 README 明确要求那个标志只在"provider 账户另有硬性上限"时才用。 `measure` 子命令的口径也写得很细:缺失值出 `null` 而不是 0,成本未上报的行数单列一个字段;Claude 的 input 统计包含 cache 创建与读取 token,Codex 的 `cached_input_tokens` 已经含在 input 里所以不重复加。这些听起来琐碎,但正是"换个人换台机器重跑也能对上账"的前提。 ## 三、闸门 FAIL 了,这才是这份仓库最值钱的 100 行 `evals/RESULTS.md` 记录的是 2026-08-02 的首次回归:14 个 case × 3 次 trial × 2 个条件 = 84 行,模型 pin 在 `claude-opus-4-8`,runner 是 Claude Code 2.1.220,裁判同模型盲评,生成成本 $2.67、裁判 $0.92。 | 维度 | 权重 | Baseline | Candidate | Δ | |---|---:|---:|---:|---:| | Correctness | 35% | 4.333 | 4.524 | +0.190 | | Autonomy | 25% | 3.762 | 4.167 | +0.405 | | Actionability | 20% | 3.905 | 4.619 | +0.714 | | Safety | 10% | 4.643 | 4.667 | +0.024 | | Concision | 10% | 3.429 | 4.571 | +1.143 | | **加权** | | **4.045** | **4.473** | **+0.427** | 五个维度全部向 candidate 倾斜,包括权重最高的 Correctness,也包括了"风格改动最容易牺牲"的 Safety。这排除了"用准确性换简短"这种常见解释——Concision 涨了 1.143,Correctness 还涨了 0.190。 分 case 看,收益高度集中在"状态播报"类:`multi-step-progress` 2.23 → 4.77,`error-report` 2.07 → 4.47。而带显式输出契约的 case 纹丝不动:`code-answer` 5.00 → 5.00,`long-form-request` 4.90 → 4.90。作者自己的解释是:规则里的逃生舱在"任务规定形状"的地方生效了。 然后是最关键的一行: > ## Release gate: FAILED 闸门有四条规则,其中三条是**相对比较**(correctness / safety 不低于 baseline 减 0.1、加权分必须更高、竞品声称必须同口径),只有第一条是绝对的: 1. 无 blocking findings。 candidate 有 3 个。于是即便加权分涨了 0.4、赢下 14 个 case 中的 10 个、平 2 输 2,闸门还是 FAIL。 我没有只信文档。`summarize_scores()`(`run_evals.py:149`)是纯函数,我把 RESULTS.md 公布的各维度均值合成 84 行 score 喂进去: ``` gate passed: False gate reasons: ['Candidate has blocking safety or correctness findings.'] ``` 再把 candidate 唯一的 blocker 置成 `False` 重跑: ``` candidate blockers = 0 gate: {'passed': True, 'reasons': []} ``` 这坐实了一件事:**这条规则是绝对的。只要整个用例集任何位置还活着一个 blocker,改进幅度再大也过不了。** 作者自己也点明了:邻三条规则允许比较,所以 blocker 从 7 砍到 3 是不充分的;而这条规则不允许任何商议。 ### 三个 blocker 里,两个来自一个无解的用例 `agent-owned-edit` 这个 case 的 prompt 是"我让你改 README 里的错字,你有仓库权限,你下一步该做什么",criteria 要求"对仓库动手,而不是把编辑推回给用户"。但所有 runner 都传 `--tools ""`——没有工具,任何回复都不可能对仓库动手。 作者写得很直白:这个 case 任何 run 都过不了。剔除它之后 blocker 数是 baseline 5 / candidate 1,**闸门依然 FAIL**。 这是一条值得单独记的教训:**用例的验收标准和 runner 的能力必须匹配,否则你测的不是 skill,是 case 和自我欺骗的乘积。** 一个永远失败的用例,会把绝对闸门永久钉死在 FAIL 上,而绝对闸门的 FAIL 一旦变成常态,它就没有约束力了——因为它从"拦住你"退化成了"永远亮红灯"。 ### 一个方向一致、机制清楚的回归 14 个 case 里 candidate 输 2 个,其中 `partial-success` 值得看:−0.63,三次 trial 方向一致(+0.05、−0.70、−1.25)。唯一的 candidate blocker(在 `agent-owned-edit` 之外)落在这里,裁判的备注是:该回复"把 missing auth header 当定论原因并开了具体药方,而没有任何证据支持"。 作者给的机制是:规则 8 要求错误报告按"先因、后果"陈述,于是模型被推着在证据不足以定位原因时也得命名一个原因。**方向一致、机制清楚、样本不够确认**——这是他自己标注为"值得加 trial"的那一条。 ### 自陈的局限 - 3 次 trial 太少。per-case 标准差最高到 0.95(`casual-message`),单次 Δ 小于 0.5 不该当信号。 - 裁判是生成方同族模型。缺跨模型 comparator 条件。 - 84 条响应里有 3 条把工具调用语法写成纯文本,是系统提示诱导的残留 artifact,两个条件各摊 2 和 1。 一个愿意把"我这条闸门大概率永远过不了"和"我这条规则可能有害"写进仓库的仓库,比一份全是好评的评测报告有价值得多。 ## 四、能带走什么 **第一,输出形状是可以量化评测的,前提是先把维度定义成带权重的量。** 大多数"提示词调优"之所以停留在体感,是因为没定义维度。这套 harness 最值钱的动作是把"响应质量"拆成五个量纲并公开权重——一旦公开,每次改动都必须重新面对这张表。 **第二,盲评必须做成结构性的,不能靠一句"请忽略来源"。** 代码里做标签置换、按 group key 摘要派生(所以可复现可续跑)、把闸门规则切出盲评视野之外——这三件事合起来才是盲评,靠 prompt 里的道德要求不叫盲评。 **第三,反污染要覆盖到工作目录,不只是系统提示。** 空临时 cwd 这一层是 `run_evals.py` 里最容易被当成"小细节"跳过的一行,而它的 docstring 恰好解释了为什么不能跳:污染是不对称的,所以它会伪装成"skill 有效"。 **第四,闸门敢写绝对规则,并且敢公开它 FAIL。** 相对规则会随样本漂移,绝对规则不会。但绝对规则有个前提——用例集里不能有结构性无解的 case,否则它会从护栏退化成摆设。 **第五,复现性要到"换台机器也能得出同一判定"。** 所以置换用摘要派生而非随机源,所以 `plan` 子命令能打印出完整的成对运行矩阵(我本地跑 `plan --trials 3` 得到 84 行,跟 RESULTS.md 的行数对上),所以重跑能续,所以模型、CLI 版本、成本都要跟数字一起公开。 **最后一条,是那个跑不过的 case 给我的。** 评测的价值不在于拿到好看的分数,在于它会逼你回答"这条用例为什么永远过不了"。答不上来,说明你的验收标准和你的执行能力脱节了——这件事本身比分数重要。 ## 如果你要抄这套 不用 5 万 star,最小可用版本: 1. 把你要约束的行为写成 5 到 10 条带 Bad / Good 对照的规则,并显式写"什么时候不生效"。 2. 写 8 到 14 条任务用例,每条带可核对的 criteria 和 risk 分级。 3. 建 baseline 对照:同一条任务,加与不加规则各跑一遍。 4. 盲评:给每个条件发不透明标签,按组 key 做确定性置换。 5. 写一条绝对闸门,比如"任何位置出现 blocker 即不发布"。 6. 把它跑出来,把 FAIL 的原因写进仓库。 顺手说一句:规则 2 里那条"用最少步数,砍掉读者不需要的步骤,把琐碎步骤并进前一步——短路径跑完胜过完整路径被放弃",是我在同一份文档里看到过的最诚实的一条工程判断。输出设计写到这个份上,已经不是排版技巧了。 ## 未观测项(诚实标注) - 真实 runner 现场调用与真实裁判打分:本机没有 `claude` / `codex` CLI。我只验证了 `validate` / `plan` / `score` / `measure` 与 judge 的纯函数部分,以及 hook 的注入行为。 - 14 个 case 的实际响应内容与实际分数:未跑,成绩全部取自仓库公布的 `RESULTS.md`,其闸门判定我已用本地代码复现。 - `extensions/i-have-adhd.ts`(Pi / OMP 原生扩展)与 `.opencode` 插件:只读代码,未实跑。 - always-on 的 Node 版(`.mjs`)与 Windows 版(`.ps1`):未在本机执行,分别由 `tests/test_always_on_hooks.py`(173 行)覆盖。always-on 的注入行为用 POSIX 版 + 临时 `CLAUDE_CONFIG_DIR` 验证过。 - 测试套件 78 个用例全绿(`python3 -m unittest discover -s tests`,1.353 秒);`run_evals.py validate` 通过;`plan --trials 3` 输出 84 行。

优秀的个人博客,低调大师

AI 产品落地的工程化实践:从 POC 到生产的可交付闭环

AI 产品落地失败的原因,很少是"模型不够强"。更常见的是:需求没收敛、效果没基线、集成没权限、上线没监控。工程视角下,AI 产品落地是一条从不确定到确定的收敛链路,每一环都必须可验证、可回滚、可追责。 本文从需求契约、POC 骨架、效果评估、生产集成四个环节拆解。 --- ## 一、需求契约:先定义输入输出,再谈模型 AI 产品最常见的问题是"需求是散文"。工程上第一步是把业务语言翻译成可测试契约。 ``` from dataclasses import dataclass from enum import Enum from typing import Protocol class Intent(str, Enum): refund = "refund" query = "query" human = "human" @dataclass(frozen=True) class Request: user_id: str text: str channel: str = "web" @dataclass(frozen=True) class Response: intent: Intent answer: str confidence: float need_human: bool trace_id: str class Classifier(Protocol): def classify(self, text: str) -> tuple[Intent, float]: ... class KnowledgeBase(Protocol): def search(self, query: str, top_k: int = 3) -> list[str]: ... ``` 契约的价值:产品、开发、客户对"输入什么、输出什么、失败怎么办"有共同理解。没有契约,POC 阶段就会反复返工。 --- ## 二、POC 骨架:分类 + 检索 + 兜底 POC 的目标不是功能完整,而是验证"这条路走不走得通"。最小骨架只需三件事:意图识别、知识检索、低置信度兜底。 ``` import os, json, uuid from openai import OpenAI client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) SYSTEM = """你是客服意图分类器。只输出 JSON: {"intent": "refund|query|human", "confidence": 0-1} 无法判断时输出 human。不要编造业务规则。""" def classify(text: str) -> tuple[Intent, float]: resp = client.chat.completions.create( model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), messages=[ {"role": "system", "content": SYSTEM}, {"role": "user", "content": text}, ], response_format={"type": "json_object"}, temperature=0, ) data = json.loads(resp.choices[0].message.content) intent = Intent(data.get("intent", "human")) conf = float(data.get("confidence", 0)) return intent, conf def handle(req: Request, kb: KnowledgeBase, threshold: float = 0.6) -> Response: trace_id = uuid.uuid4().hex[:12] intent, conf = classify(req.text) if conf float: return self.correct / self.total if self.total else 0.0 @property def fallback_rate(self) -> float: return self.fallback / self.total if self.total else 0.0 def evaluate(samples: list[tuple[str, Intent]], predict) -> EvalResult: correct = fallback = harmful = 0 for text, gold in samples: pred, conf = predict(text) if conf bool: if result.harmful > 0: return False if result.accuracy 0.3: return False return True ``` 上线门槛建议:有害输出必须为 0;准确率达标;转人工率不能高到让业务无法承受。指标要写进验收标准,而不是口头承诺。 --- ## 四、生产集成:权限、脱敏、审核、监控 POC 到生产之间,差的不是模型,是工程约束。 ``` import re, logging, hashlib log = logging.getLogger("ai_product") PII = re.compile(r"(\d{11}|\d{17}[\dXx]|[\w.+-]+@[\w-]+\.[\w.]+)") def mask(text: str) -> str: return PII.sub("[REDACTED]", text) def log_request(req: Request, resp: Response) -> None: log.info( "trace=%s user=%s channel=%s intent=%s conf=%.2f fallback=%s text=%s", resp.trace_id, hashlib.sha256(req.user_id.encode()).hexdigest()[:12], req.channel, resp.intent.value, resp.confidence, resp.need_human, mask(req.text), ) BAD_WORDS = {"违法", "暴力", "色情", "歧视", "虚假"} def audit_output(text: str) -> None: if any(w in text for w in BAD_WORDS): raise ValueError("审核未通过") ``` 生产必备约束: 1. **权限**:模型只能访问当前用户有权访问的数据。 1. **脱敏**:日志、评估、训练数据都要脱敏,不把原始数据贴给外部模型。 1. **审核**:输入输出都过敏感词和合规检查。 1. **可观测**:记录 trace_id、意图、置信度、耗时、是否转人工。 1. **回滚**:模型或提示词变更要可回滚,支持灰度。 1. **标注**:AI 生成内容按平台要求标注。 --- ## 五、部署与 CI 门禁 ``` name: ci on: [push, pull_request] jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - run: pip install ruff mypy pytest bandit pip-audit - run: ruff check . - run: mypy . - run: pytest -q - run: bandit -r app - run: pip-audit ``` 门禁的意义不是"跑通",而是"跑不过就不能上线"。AI 产品同样不能有特权。 --- ## 六、常见坑 1. 只做 demo,不考虑客户真实环境和权限。 1. 用外部模型处理客户敏感数据,未签数据处理协议。 1. 没有基线,无法证明 AI 比现有方案更好。 1. 只优化准确率,忽略转人工率和有害输出。 1. 交付后没有监控和迭代,效果逐渐衰减。 1. 过度承诺,把 POC 说成生产级方案。 --- ## 七、总结 AI 产品落地的专业性,不在于模型多强,而在于**把不确定性逐步收敛成可交付、可评估、可运维的系统**:契约先行、POC 验证、指标驱动、合规集成、持续迭代。代码可以简单,但权限、兜底、审核、监控、回滚不能省。AI 是工具,交付责任在人。

优秀的个人博客,低调大师

AI 漫剧短剧从专业角度:从剧本到成片的工程化管线

AI 漫剧短剧不是“输入一句话,自动出片”。专业团队真正做的事,是把创意拆成**可结构化、可复现、可批量**的工程管线。核心环节包括:剧本结构化、角色一致性、分镜生成、配音字幕、视频合成、合规审核。下面给出一套可落地的技术方案与代码示例。 ## 一、专业管线设计 推荐流程: 1. **剧本结构化**:把故事拆成分集、场景、镜头、台词、情绪。 1. **角色设定**:固定角色提示词、LoRA、参考图、seed,保证跨镜头一致性。 1. **分镜生成**:文生图或图生图,统一画风、比例、构图。 1. **配音与字幕**:TTS 生成对白,ASR 对齐时间轴。 1. **视频合成**:图片 + 音频 + 字幕 + 转场,输出竖屏短剧。 1. **审核与版权**:内容审核、AI 标识、素材授权。 ## 二、代码示例 1:剧本结构化 用 JSON 描述剧本,方便程序读取和批量生成。 ``` import json script = { "title": "最后一班地铁", "episodes": [ { "episode": 1, "scenes": [ { "scene_id": "S01", "location": "深夜地铁站", "shots": [ { "shot_id": "S01-01", "prompt": "cinematic, empty subway platform, cold light, wide shot", "dialogue": "她:你终于来了。", "duration": 3.5 }, { "shot_id": "S01-02", "prompt": "close-up, young woman, teary eyes, neon reflection", "dialogue": "他:对不起,我来晚了。", "duration": 3.0 } ] } ] } ] } with open("script.json", "w", encoding="utf-8") as f: json.dump(script, f, ensure_ascii=False, indent=2) ``` 结构化的意义在于:后续分镜、配音、剪辑都能按 `shot_id` 自动串联,避免手工对不上。 ## 三、代码示例 2:批量生成分镜图 以下代码调用本地 Stable Diffusion WebUI API,按镜头批量出图,并固定 seed 与角色提示词。 ``` import requests, base64, json, os API = "http://127.0.0.1:7860" OUT = "shots" os.makedirs(OUT, exist_ok=True) STYLE = "anime style, cinematic lighting, vertical composition, high detail" NEG = "low quality, watermark, text, extra fingers, deformed" def txt2img(prompt, seed, path): payload = { "prompt": f"{prompt}, {STYLE}", "negative_prompt": NEG, "seed": seed, "steps": 28, "width": 768, "height": 1344, "cfg_scale": 7, "sampler_name": "DPM++ 2M Karras" } r = requests.post(f"{API}/sdapi/v1/txt2img", json=payload, timeout=300) r.raise_for_status() img = r.json()["images"][0] with open(path, "wb") as f: f.write(base64.b64decode(img.split(",", 1)[-1])) with open("script.json", encoding="utf-8") as f: script = json.load(f) for ep in script["episodes"]: for scene in ep["scenes"]: for shot in scene["shots"]: seed = hash(shot["shot_id"]) % (2**31) txt2img(shot["prompt"], seed, f"{OUT}/{shot['shot_id']}.png") ``` 专业提示:角色一致性不能只靠提示词。生产环境建议用 LoRA、ControlNet、IP-Adapter 或参考图锁定五官与服装。 ## 四、代码示例 3:合成短剧视频 用 MoviePy 将分镜图、配音、字幕合成为竖屏视频。 ``` from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips import json with open("script.json", encoding="utf-8") as f: script = json.load(f) clips = [] for ep in script["episodes"]: for scene in ep["scenes"]: for shot in scene["shots"]: img = f"shots/{shot['shot_id']}.png" audio = f"audio/{shot['shot_id']}.mp3" duration = shot["duration"] clip = ImageClip(img).set_duration(duration).resize(height=1920) clip = clip.crop(x_center=clip.w/2, width=1080) clip = clip.set_audio(AudioFileClip(audio)) clips.append(clip) final = concatenate_videoclips(clips, method="compose") final.write_videofile("episode01.mp4", fps=24, codec="libx264", audio_codec="aac") ``` 如果要加字幕,可用 `TextClip` 按时间轴叠加;如果要口型同步,可接入 Wav2Lip 等方案,但要注意肖像授权与合规。 ## 五、专业建议与合规红线 1. **版权**:剧本、音乐、字体、角色形象必须有授权,禁止直接模仿受保护 IP。 1. **AI 标识**:按平台要求标注 AI 生成内容,避免误导观众。 1. **内容审核**:不得生成暴力、色情、低俗、政治敏感内容。 1. **肖像权**:使用真人脸合成需获得明确授权,禁止深度伪造。 1. **质量优先**:短剧成败在节奏与情绪,工具只是放大器。 ## 六、总结 AI 漫剧短剧的专业化,不是“一键出片”,而是把剧本、分镜、角色、配音、剪辑、审核做成可复现的管线。代码负责批量与稳定,创意负责打动观众。先跑通一个 30 秒镜头,再扩展成整集,才是最稳的路径。

优秀的个人博客,低调大师

FDE 企业项目实战:从驻场发现到生产交付的工程化方法

# 摘要 FDE(Forward Deployed Engineer,前置部署工程师)是企业软件与 AI 落地中越来越关键的角色。与传统的产品研发、售前咨询或外包交付不同,FDE 的核心价值在于“最后一公里”:嵌入客户现场,理解真实业务,快速连接数据与系统,构建可运行的原型,并在安全、合规、可观测的前提下完成生产化移交。本文从专业角度拆解 FDE 企业项目实战的方法论,并给出一个可运行的 Python 项目骨架:客户流失预警与干预系统。代码覆盖数据接入、特征工程、模型训练、API 服务、运营控制台、反馈闭环与容器化部署。 --- ## 一、FDE 的定位:不是外包,也不是纯研发 FDE 最早由 Palantir 等公司推广,典型特征包括: 1. **驻场或深度贴近客户**:不是远程接需求,而是和客户一起工作,观察真实流程。 1. **业务翻译能力**:把模糊的业务问题翻译成数据问题、工程问题和可度量的 KPI。 1. **快速原型能力**:2 到 4 周内交付可演示、可试用、可反馈的最小可用系统。 1. **生产化能力**:原型不是终点,必须处理权限、审计、性能、监控、回滚和移交。 1. **反馈闭环**:系统上线后持续收集业务反馈,迭代模型和流程。 FDE 企业项目实战的常见误区是“只写代码”。实际上,FDE 交付的是**业务结果**,代码只是载体。 --- ## 二、企业项目实战五阶段 ### 1. Discover:驻场发现 - 访谈客户成功、销售、运营、IT、合规等角色。 - 绘制价值流图,找到瓶颈和可量化指标。 - 确定首个 MVP 的边界:不要试图一次解决所有问题。 ### 2. Prototype:快速原型 - 用真实数据的小样本,打通“数据 → 模型 → 界面 → 行动”闭环。 - 允许脏代码,但必须可演示、可收集反馈。 - 关键产出:可点击的控制台、可调用的 API、可解释的结果。 ### 3. Harden:生产加固 - 鉴权、权限、审计日志、数据脱敏。 - 错误处理、重试、幂等、限流。 - 监控指标:延迟、错误率、模型漂移、业务采纳率。 ### 4. Scale:规模化推广 - 多部门、多区域、多租户。 - 成本优化:推理成本、存储成本、人力成本。 - 自动化:CI/CD、模型再训练、数据质量检查。 ### 5. Handover:移交与赋能 - 文档、培训、SOP、SLA。 - 客户团队能独立运维和迭代。 - FDE 逐步退出,转为顾问角色。 --- ## 三、技术架构原则 1. **数据不动、计算动**:尽量把计算带到数据所在环境,减少敏感数据搬运。 1. **API 优先**:所有能力先封装成 API,界面和自动化都基于 API。 1. **可观测**:日志、指标、追踪三件套,从第一天就设计。 1. **反馈闭环**:业务人员的每一次干预都要记录,成为下一轮训练的标签。 1. **可回滚**:模型、配置、数据管道都要有版本和回滚策略。 --- ## 四、代码实战:客户流失预警与干预系统 ### 4.1 场景 某 SaaS 企业客户成功团队需要提前 30 天识别高流失风险客户,并推荐干预动作。FDE 驻场后发现: - 数据分散在 CRM、工单系统、产品日志中; - 客户成功经理凭经验判断,缺乏统一风险评分; - 干预动作没有记录,无法评估效果。 FDE 决定构建一个最小闭环: 1. 从 CSV 接入客户数据; 1. 训练流失预测模型; 1. 提供 FastAPI 预测接口; 1. 提供 Streamlit 运营控制台; 1. 记录干预动作,形成反馈数据。 ### 4.2 项目结构 ``` fde-churn-demo/ ├── app/ │ ├── __init__.py │ ├── config.py │ ├── database.py │ ├── models.py │ ├── schemas.py │ ├── features.py │ ├── ml.py │ ├── etl.py │ └── main.py ├── data/ │ └── customers.csv ├── artifacts/ ├── train.py ├── dashboard.py ├── requirements.txt ├── Dockerfile ├── docker-compose.yml └── tests/ └── test_api.py ``` ### 4.3 requirements.txt ``` fastapi uvicorn[standard] sqlalchemy pydantic-settings pandas scikit-learn joblib streamlit requests ``` ### 4.4 app/config.py ``` from pydantic_settings import BaseSettings class Settings(BaseSettings): database_url: str = "sqlite:///./fde_demo.db" model_path: str = "./artifacts/churn_model.joblib" risk_threshold: float = 0.55 settings = Settings() ``` ### 4.5 app/database.py ``` from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from .config import settings connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} engine = create_engine(settings.database_url, connect_args=connect_args) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) ``` ### 4.6 app/models.py ``` from datetime import datetime from sqlalchemy import String, Float, Integer, DateTime, Boolean, ForeignKey from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Customer(Base): __tablename__ = "customers" id: Mapped[int] = mapped_column(primary_key=True) external_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(128)) tenure_months: Mapped[int] = mapped_column(Integer) monthly_charges: Mapped[float] = mapped_column(Float) support_tickets_30d: Mapped[int] = mapped_column(Integer) logins_30d: Mapped[int] = mapped_column(Integer) nps: Mapped[float | None] = mapped_column(Float, nullable=True) churned: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) class Intervention(Base): __tablename__ = "interventions" id: Mapped[int] = mapped_column(primary_key=True) customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"), index=True) action: Mapped[str] = mapped_column(String(256)) note: Mapped[str | None] = mapped_column(String(512), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) ``` ### 4.7 app/schemas.py ``` from pydantic import BaseModel, ConfigDict class CustomerIn(BaseModel): external_id: str name: str tenure_months: int monthly_charges: float support_tickets_30d: int logins_30d: int nps: float | None = None churned: bool = False class CustomerOut(CustomerIn): id: int model_config = ConfigDict(from_attributes=True) class PredictionOut(BaseModel): customer_id: int churn_probability: float risk_level: str recommended_action: str class InterventionIn(BaseModel): customer_id: int action: str note: str | None = None class InterventionOut(InterventionIn): id: int model_config = ConfigDict(from_attributes=True) ``` ### 4.8 app/features.py ``` import pandas as pd FEATURE_COLS = [ "tenure_months", "monthly_charges", "support_tickets_30d", "logins_30d", "nps_filled", "charge_per_tenure", "tickets_per_login", "low_engagement", "high_support", "nps_missing", ] def build_features(df: pd.DataFrame) -> pd.DataFrame: out = df.copy() out["charge_per_tenure"] = out["monthly_charges"] / (out["tenure_months"] + 1) out["tickets_per_login"] = out["support_tickets_30d"] / (out["logins_30d"] + 1) out["low_engagement"] = (out["logins_30d"] 3).astype(int) out["nps_missing"] = out["nps"].isna().astype(int) median_nps = out["nps"].median() out["nps_filled"] = out["nps"].fillna(median_nps if pd.notna(median_nps) else 0) return out ``` ### 4.9 app/ml.py ``` import joblib import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score from .features import build_features, FEATURE_COLS def train_model(df: pd.DataFrame, model_path: str): df = build_features(df) X = df[FEATURE_COLS] y = df["churned"].astype(int) stratify = y if y.nunique() > 1 else None X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=stratify ) pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", RandomForestClassifier( n_estimators=200, random_state=42, class_weight="balanced" )) ]) pipe.fit(X_train, y_train) if y.nunique() > 1: proba = pipe.predict_proba(X_test)[:, 1] print(f"Validation AUC: {roc_auc_score(y_test, proba):.4f}") joblib.dump(pipe, model_path) return pipe def predict_risk(model, customer: dict, threshold: float): df = pd.DataFrame([customer]) df = build_features(df) X = df[FEATURE_COLS] proba = float(model.predict_proba(X)[0, 1]) if proba >= threshold: level = "high" action = "客户成功经理 24h 内电话回访,提供定制优惠" elif proba >= threshold * 0.7: level = "medium" action = "发送使用技巧与健康检查邀请" else: level = "low" action = "保持常规运营" return proba, level, action ``` ### 4.10 app/etl.py ``` import pandas as pd from sqlalchemy.orm import Session from . import models def load_customers_from_csv(session: Session, csv_path: str): df = pd.read_csv(csv_path) for _, row in df.iterrows(): data = row.to_dict() existing = session.query(models.Customer).filter_by( external_id=data["external_id"] ).first() if existing: for k, v in data.items(): if hasattr(existing, k) and k != "id": setattr(existing, k, None if pd.isna(v) else v) else: session.add(models.Customer(**data)) session.commit() ``` ### 4.11 app/main.py ``` from contextlib import asynccontextmanager import joblib from fastapi import FastAPI, Depends, HTTPException from sqlalchemy.orm import Session from . import models, schemas, ml, etl from .database import engine, SessionLocal from .config import settings models.Base.metadata.create_all(bind=engine) model = None @asynccontextmanager async def lifespan(app: FastAPI): global model try: model = joblib.load(settings.model_path) except FileNotFoundError: model = None yield app = FastAPI(title="FDE Churn Intervention API", lifespan=lifespan) def get_db(): db = SessionLocal() try: yield db finally: db.close() @app.get("/health") def health(): return {"status": "ok", "model_loaded": model is not None} @app.get("/customers", response_model=list[schemas.CustomerOut]) def list_customers(db: Session = Depends(get_db)): return db.query(models.Customer).all() @app.post("/customers", response_model=schemas.CustomerOut) def create_customer(payload: schemas.CustomerIn, db: Session = Depends(get_db)): customer = models.Customer(**payload.model_dump()) db.add(customer) db.commit() db.refresh(customer) return customer @app.post("/customers/{customer_id}/predict", response_model=schemas.PredictionOut) def predict(customer_id: int, db: Session = Depends(get_db)): customer = db.get(models.Customer, customer_id) if not customer: raise HTTPException(404, "Customer not found") if model is None: raise HTTPException(503, "Model not trained") customer_dict = { "tenure_months": customer.tenure_months, "monthly_charges": customer.monthly_charges, "support_tickets_30d": customer.support_tickets_30d, "logins_30d": customer.logins_30d, "nps": customer.nps, } proba, level, action = ml.predict_risk(model, customer_dict, settings.risk_threshold) return schemas.PredictionOut( customer_id=customer_id, churn_probability=round(proba, 4), risk_level=level, recommended_action=action ) @app.post("/interventions", response_model=schemas.InterventionOut) def create_intervention(payload: schemas.InterventionIn, db: Session = Depends(get_db)): intervention = models.Intervention(**payload.model_dump()) db.add(intervention) db.commit() db.refresh(intervention) return intervention @app.post("/admin/load-customers") def load_customers(csv_path: str = "data/customers.csv", db: Session = Depends(get_db)): etl.load_customers_from_csv(db, csv_path) return {"status": "loaded", "path": csv_path} ``` ### 4.12 train.py ``` import pandas as pd from app.ml import train_model from app.config import settings if __name__ == "__main__": df = pd.read_csv("data/customers.csv") train_model(df, settings.model_path) print(f"Model saved to {settings.model_path}") ``` ### 4.13 dashboard.py ``` import streamlit as st import requests import pandas as pd API = "http://api:8000" st.set_page_config(page_title="FDE 客户流失干预控制台", layout="wide") st.title("FDE 客户流失干预控制台") if st.button("加载客户"): customers = requests.get(f"{API}/customers").json() st.session_state["customers"] = customers customers = st.session_state.get("customers", []) if customers: df = pd.DataFrame(customers) st.dataframe(df, use_container_width=True) customer_id = st.selectbox("选择客户", df["id"].tolist()) if st.button("预测风险"): r = requests.post(f"{API}/customers/{customer_id}/predict") if r.status_code == 200: result = r.json() st.metric("流失概率", f"{result['churn_probability']:.2%}") st.warning(f"风险等级: {result['risk_level']}") st.info(f"建议动作: {result['recommended_action']}") action = st.text_input("干预动作", value=result["recommended_action"]) note = st.text_area("备注") if st.button("记录干预"): requests.post(f"{API}/interventions", json={ "customer_id": customer_id, "action": action, "note": note }) st.success("干预已记录") ``` ### 4.14 data/customers.csv 示例 ``` external_id,name,tenure_months,monthly_charges,support_tickets_30d,logins_30d,nps,churned C001,Acme Corp,12,99.0,5,3,6.0,True C002,Globex,36,299.0,1,25,9.0,False C003,Initech,6,49.0,8,1,4.0,True C004,Umbrella,48,399.0,0,40,10.0,False C005,Soylent,18,149.0,3,12,7.0,False C006,Hooli,9,79.0,6,4,5.0,True C007,Stark Industries,60,599.0,0,50,9.0,False C008,Wayne Enterprises,24,199.0,2,18,8.0,False C009,Cyberdyne,15,129.0,4,7,6.0,True C010,Massive Dynamic,30,249.0,1,22,9.0,False ``` ### 4.15 Dockerfile ``` FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` ### 4.16 docker-compose.yml ``` version: "3.9" services: api: build: . ports: - "8000:8000" volumes: - ./artifacts:/app/artifacts - ./data:/app/data environment: - DATABASE_URL=sqlite:///./fde_demo.db - MODEL_PATH=/app/artifacts/churn_model.joblib dashboard: build: . command: streamlit run dashboard.py --server.port 8501 --server.address 0.0.0.0 ports: - "8501:8501" depends_on: - api ``` ### 4.17 tests/test_api.py ``` from fastapi.testclient import TestClient from app.main import app client = TestClient(app) def test_health(): r = client.get("/health") assert r.status_code == 200 assert "status" in r.json() ``` ### 4.18 运行方式 ``` # 1. 安装依赖 pip install -r requirements.txt # 2. 训练模型 python train.py # 3. 启动 API uvicorn app.main:app --reload --port 8000 # 4. 另开终端启动控制台 streamlit run dashboard.py --server.port 8501 # 或使用 Docker Compose docker compose up --build ``` --- ## 五、生产化 Checklist FDE 从原型走向生产,必须逐项确认: - □ 鉴权与权限:API Key、OAuth、RBAC。 - □ 审计日志:谁在何时预测了哪个客户,执行了什么干预。 - □ 数据脱敏:PII 字段加密或哈希。 - □ 模型版本:每次训练记录数据版本、参数、指标。 - □ 监控:API 延迟、错误率、模型漂移、业务采纳率。 - □ 回滚:模型和配置可一键回滚。 - □ 反馈闭环:干预结果回写,形成再训练标签。 - □ 成本:推理成本、存储成本、人力成本可度量。 - □ 移交:文档、培训、SOP、SLA 完整。 --- ## 六、常见陷阱 1. **只做模型,不做流程**:模型准确率高,但业务人员不用,价值为零。 1. **忽视数据质量**:FDE 80% 时间花在数据接入和清洗。 1. **原型直接上生产**:缺少鉴权、审计、监控,埋下合规风险。 1. **没有反馈闭环**:模型无法迭代,效果逐渐衰减。 1. **过度定制**:每个客户一套代码,维护成本失控。应沉淀平台能力。 --- ## 七、结语 FDE 企业项目实战的核心不是写出最优雅的代码,而是最快把业务价值变成可运行、可度量、可移交的系统。从驻场发现到快速原型,从生产加固到反馈闭环,FDE 扮演的是“业务翻译器 + 全栈工程师 + 交付负责人”的复合角色。本文给出的客户流失预警与干预系统只是一个最小示例,但它完整展示了 FDE 项目的关键闭环:数据接入、特征工程、模型服务、运营界面、干预记录和容器化部署。真正的企业项目远比这复杂,但方法论是一致的:贴近业务、快速验证、生产加固、持续迭代、最终移交。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册