这是一篇工作复盘。一天里我们在一个芯片数据平台(下称项目A)上推进了 10 个变更,横跨前端、桌面端、四个后端服务和一个 Python 检测服务。表面上是零散的功能和 bug 修复,串起来看其实是四条清晰的主线:一条检测链路的彻底重构、一条桌面端鉴权与数据层的标准化、一个困扰已久的 Web 掉线幽灵 bug,以及一次"做了一半的迁移"被补全

本文不讲流水账,只讲每条主线里值得记下的技术决策和那些隐蔽的坑。

涉及的系统范围

后端

客户端

webRequest 改写

只读

前端 Web 应用

桌面端

核心服务端

文档服务端

业务服务端

Agent 服务

检测服务
Python

共享 PostgreSQL

本地鉴权网关

图中所有真实服务名已脱敏为角色名。下面按四条主线展开。


主线一:芯片检测链路的彻底重构

业务线A 下的四个业务模块(业务模块甲、业务模块乙、业务模块丙、业务模块丁)原来都依赖一个臃肿的 检测服务(Python),它把三种性质迥异的能力混在一起:YOLO 板卡检测、PaddleOCR 丝印提取、LangGraph 智能体搜索。一天里我们对这条链路连下三刀。

1.1 服务瘦身:把混合服务拆回单一职责

旧的检测服务有三个痛点:

  • 用进程内字典 _sessions 维护会话,无 TTL、无持久化、重启即丢、多 worker 不可用
  • 跨步骤的 session_id 把"检测"和"分析"死死耦合,无法独立演进;
  • LangGraph + Tavily + langchain 这套 Python 依赖很重,而 Agent 服务(Go)早已有同构能力(主控 Agent + 联网搜索工具,后者本就是 Tavily),属于严重的重复建设。

方案是让检测服务回归单纯的 YOLO 板卡检测职责

重构后

直读

业务服务端
OCR 编排+提示词

检测服务
纯 YOLO

Agent 服务
搜索能力

共享数据库

重构前

业务服务端

检测服务
YOLO+OCR+Agent 混合

几个关键决策:

  • OCR 编排上移到业务服务端:把 PaddleOCR 的 Python 编排逻辑翻译成 Go(提交 job → 轮询 → 解析 jsonl → 拼接丝印文本),原 Python 实现删除。
  • 搜索能力下沉到 Agent 服务:业务服务端把"业务提示词 + 丝印文本"拼成 user_prompt,通过 AgentKit 的通用 Exec 接口传给已注册的校验 Agent;Agent 服务零改动、不感知这些提示词。
  • session 概念彻底消除:检测服务只返回裁剪图的 base64,业务服务端自己在内存里跑完 base64 → 丝印文本 → 报告 的完整数据流,不再有跨步骤会话。
  • 共享数据库只读访问:业务服务端通过 ORM driver 类型断言拿到原生 *sql.DB,用原生 SQL 只读查询与文档服务端共享的 PostgreSQL(产品 / 厂商 / 分类表),不引入第二个 ORM。

收获:当两个服务已有同构能力时,重复实现就是债。这次把"谁擅长什么"划清——Python 只做它必须做的 YOLO 推理,Go 接管编排与智能体调用。

1.2 去 OCR:用多模态一步到位

瘦身之后马上冒出下一个问题:原来的识别是两阶段——先 PaddleOCR 把芯片裁图压平成丝印文字字符串,再让纯文本 Agent 去查芯片属性。这个 OCR→文本的交接丢失了视觉上下文(封装标识、丝印布局、多行阅读顺序),而且养着一个独立的 OCR 服务作为硬依赖。

巧的是,校验 Agent 跑的那台多模态大模型本来就支持视觉输入,只是 AgentKit 的 Exec 接口没把图片能力暴露出来——输入被硬编码成纯文本 UserMessage。多模态在交互式会话路径里早已存在,唯独批量调用这条路径够不到。

于是把所有图片路径的"OCR + Agent"两阶段,合并成一次多模态 Agent 调用

单阶段(新)

芯片裁图

多模态 Agent
直接读图 + 查库

报告

两阶段(旧)

芯片裁图

PaddleOCR

丝印文本

纯文本 Agent

报告

实现要点:

  • 扩展 Exec 接口接受图片:有图时构建多模态消息(图片 part + 文本 part),无图时保持纯文本路径向后兼容
  • 批量场景(业务模块甲 / 业务模块丙)改成单图并发流水线:每个裁图一次调用,自适应并发(遇限流自动降并发,持续成功后恢复),业务服务端自己汇总并按位置赋 Index
  • 重写提示词为图片输入版:Agent 直接从图里读丝印、做逻辑校验,再走"先内后外"——先查内部库,未命中再联网搜索兜底。非视觉属性(封装、厂商、分类)只来自查询结果,Agent 不凭外观猜测,避免数据不一致。
  • 彻底删除 OCR 依赖:移除其依赖注入、配置段和客户端代码。

收获:每一次"格式交接"都是信息损耗点。让模型直接看图,既提升了保真度,又顺手砍掉了一个独立服务依赖。前提是把"已存在但没接出来"的能力补上最后一公里。

1.3 同步转异步:逃掉 180 秒超时悬崖

前两步都依赖 Agent 服务,而调用方式藏着一个定时炸弹:四个业务模块全部走同步 Exec,client 默认 HTTP 超时 180 秒。而校验 Agent 是多轮 LLM + 工具调用的业务流程,单次常态就要 30~120 秒,极易撞上 180 秒悬崖;长连接还会占着连接池,被网关的空闲读超时切断。

最讽刺的是:Agent 服务早就实现了完整的异步协议(提交后返回 202 Accepted + 轮询地址),client 也早就实现了 ExecAsync / GetTaskStatus——但四个模块没有一个在用

补这"最后一公里"的关键是找到一个收口点:四个模块对 Agent 的调用全部收口在业务服务端的两个函数里。只要把这两个函数内部的同步调用换成新的 ExecAsyncWait(提交 + 轮询 + 总超时预算 + ctx 取消,返回值与原同步函数完全一致),四个模块零改动自动转异步

Agent 服务 AgentKit Client 收口函数 (ExecChipReport*) 四个业务模块 Agent 服务 AgentKit Client 收口函数 (ExecChipReport*) 四个业务模块 loop [轮询] 调用(签名不变) ExecAsyncWait 提交任务(毫秒级返回) 202 + 轮询地址 查询任务状态 completed + 结果 与同步一致的返回值 结果

限流退避的反馈环也保留了:异步任务失败时原样透传错误字符串,现有的限流识别(字符串匹配)和自适应退避语义与同步模式等价。

收获:能力存在 ≠ 被使用。当一处改动能让多个调用方集体受益时,找到那个收口点比逐个改造高效得多。


主线二:桌面端鉴权与数据层

桌面端的 token 管理原来完全依赖前端渲染进程:localStorage.TOKEN 是唯一真值,主进程被动读取,刷新也由渲染进程的 setTimeout 负责。这在 Electron 里是个糟糕的设计——渲染进程被节流、隐藏或关闭时刷新链直接断裂,token 过期后所有下游请求统一 401;渲染进程与潜在的并发刷新还会触发后端的 refresh token 复用检测,误撤销所有会话。

2.1 本地鉴权网关:token 管理大搬家

把 token 管理整体迁到桌面端本地后端(Hono),让 SQLite 成为唯一权威存储:

localhost/api/*

透明改写

注入新鲜 JWT

前端 Web 应用

Electron webRequest

本地鉴权网关 Hono

核心服务端

TokenService
SQLite + 主动刷新 + 单飞

设计要点:

  • 单行 SQLite 表作为 token 真值唯一存储(含 token、过期时间、user_id 等)。
  • TokenService 主动刷新:60 秒巡检 + 电源恢复/联网事件触发,内部用单飞 Promise 防并发(这是避免触发后端复用检测的关键)。
  • Hono 中间件接管所有 /api/*:登录/切换时捕获 JWT 存库;刷新接口直接短路返回库里的新鲜 JWT,不消耗后端 cookie,从根上消除复用检测风险;即将过期时同步触发刷新。
  • Electron webRequest 透明改写:把前端发往本地后端端口的 /api/* 请求改写到本地网关——前端 Web 应用零改动

2.2 网关透传的两个隐蔽 bug(war story)

本地网关一上线,实测立刻暴露两个缺陷,而且都极其隐蔽:

Bug 1:带查询参数的请求全部退化为默认查询。 同一个接口,?status=completed?status=processing 返回逐字节相同的响应——分页、筛选、搜索全失效。根因是代理层用 c.req.path不含 query)构造后端 URL,查询串被默默丢了。

Bug 2:二进制响应/请求体被 UTF-8 解码损坏。 一个 349,834 字节的 PDF 经网关后膨胀成 620,759 字节,且第 12 字节起就是 UTF-8 替换字符(U+FFFD)。根因是代理层用 resp.text() / c.req.raw.text() 做请求体中转——任何"解码再重编码"都会破坏二进制数据。

教训:代理层必须零字节级透传。任何解码都是 bug 源。修复方式是 query 串拼回原始 URL,请求/响应体统一透传 ReadableStream(流式零拷贝),不再按 content-type 分流。修复后,PDF 字节数与直连后端完全一致。

2.3 Drizzle 标准化:让 schema 成为唯一真相

桌面端的 Drizzle 用法一直偏离官方推荐,酿成三个具体问题:

  1. schema 与建表 SQL 双份维护schema.ts 声明字段类型,connection.ts 又手写一份 CREATE TABLE。改一处忘另一处就出 bug。
  2. 类型安全形同虚设:token 服务完全用 db.prepare(rawSqlString) 绕过 Drizzle,schema 改字段名编译期不报错、运行时才崩。
  3. 用不上官方工具链:没有 drizzle.config.ts,不能跑 generate / migrate / push / studio,每次加字段都靠手写 ALTER,无法版本化追踪。

本次把用法完整对齐官方流程:schema.ts 成为唯一 source of truth,引入 drizzle-kit 生成 baseline 迁移,所有 raw SQL 改写为 query builder,时间戳统一存为 integer(ms epoch)代价是一次性的时间戳数据迁移(ISO 字符串 → ms epoch),以及 createdAt 类型从 string 变 number 对前端展示逻辑的连带影响。

收获:ORM 不按官方用法用,等于既丢了原生 SQL 的直接,又没拿到 ORM 的类型安全与工具链——两头不讨好。


主线三:Web 端 token 刷新的幽灵 bug

这是当天最曲折的一个 bug。症状是:前端 Web 应用在 JWT 过期后无法透明刷新,用户第二天打开页面、或者页面挂久了,都会被踢回登录页——即使 refresh_token cookie 还在 30 天有效期内。

实测复现:reload + 篡改 token + 清缓存模拟"第二天访问",页面直接跳登录;网络面板里业务接口全部 401,但根本没有刷新请求跟进来

根因藏得很深,是一个模块加载时序问题:

  • 内部工具库里依赖 isomorphic-fetch,而 isomorphic-fetch模块加载时就一次性执行了 module.exports = self.fetch.bind(self),把当时的原生 fetch 快照绑死。
  • 前端应用里那个带"401 兜底重试"的 window.fetch 包装器,是在入口文件中段才执行的,晚于内部工具库的模块求值。
  • 所以上面那个工具库持有的 fetch 引用永远是原生版,绕过了包装器,刷新逻辑根本没机会触发。
  • 更糟的是框架的 mfsu(Module Federation Speed Up) 把工具库预构建成独立 vendor chunk,必然先于应用 chunk 加载——import 顺序在 mfsu 面前完全失效。

最终的修复很优雅,而且不碰内部工具库(跨项目共用包):用 pnpm patchisomorphic-fetch 打补丁,把那行 bind(self) 改成动态派发器——每次调用都现读 globalThis.fetch(即包装器装上的版本)。这样无论 mfsu 把它打到哪个 chunk、何时加载,都逃不掉包装器。同时把入口的包装器抽成独立 fetch-patch.ts 模块、在入口第一行 import,并把"定时器主动刷新"这套脆弱逻辑整体删掉,改成纯被动刷新(401 → refresh → 重发)。

收获:模块加载时序的 bug 最难查,因为代码"看着是对的"。当依赖在加载期就固化了某个引用,后续的覆盖对它无效——动态派发是根治这类问题的通用思路。


主线四:产品字段动态化——一个"做了一半的迁移"

这是一个值得所有团队警惕的案例。产品库列表页的列(封装、系列、丝印、认证、生命周期等)一直硬编码在前端,和后端的字段配置体系完全脱节——管理员调整字段,列表毫无反应。这次的诉求是让列表列由后端"产品标识"分组的字段配置动态驱动。

但真正的问题在数据层。排查发现:

  • 列表上 AI 审核入库的产品,在动态列和详情弹窗里全部显示 -
  • 根因是一次做了一半的迁移:某次提交移除了入库时"基础字段 → 产品列"的拷贝映射;之后又建立了新模型"字段统一走 ExtFields 数据源"——但只建了读取端(列表列 + Preload),从未建写入端
  • 于是入库链路既不写产品列、也不写 ExtFields,数据全滞留在结构化 JSON 里。读写两端各做了一半,中间断了。

这次的修复分三块,逻辑非常干净:

  1. 入库补写 ExtFields:自动入库 / 人工审核入库时,按已定义的字段模板,把结构化 JSON 里的值写入扩展字段表。
  2. 白名单过滤:只写入"已定义且有值"的字段,显式丢弃 AI 自行发现的字段及任何幻觉键,避免脏数据进库。
  3. 历史数据回填:对已入库但 ExtFields 为空、而源结构化数据有值的产品,做一次性回填。
  4. 数据库零结构变更:复用既有多态机制,只做数据 INSERT,不新增表/列。

同时把前端那 7 个硬编码列删掉,改为按"产品标识"分组动态生成列、从 ExtFields 读值;后端列表接口补一行 Preload("ExtFields")

收获:迁移最容易翻车的地方,是"读端先迁、写端忘了迁"的半成品状态——数据看似能跑,实则悄悄丢失。纪律是:读写两端必须成对上线,并且要有"历史数据回填"这一步兜底。另外,AI 产出的字段一定要过白名单,幻觉键绝不能直接落库。


附:一个小体验补丁

AI 审核阶段内部会触发"重提取"(自动修复提取错误的字段),但用户完全感知不到——看不到"AI 正在修",也看不到结果,于是看到审核不通过就盲目点"重新提取",而此时 AI 其实已经在背后处理了。补了一个最小的状态展示:新记录重置状态字段,前端在校验结果列增加"重提取中"分支。改动很小,但解决了"用户重复触发本已在跑的任务"的体验问题。


复盘与收获

把这一天的 10 个变更按主线串起来,几条共性的教训值得记下:

  1. 能力存在 ≠ 被使用。 异步协议、多模态能力、字段配置体系——好几个变更的本质都是"把已经实现但没接出来的能力补上最后一公里"。定期审视"已有但闲置"的能力,往往比新建更划算。
  2. 找到收口点,四两拨千斤。 四个业务模块转异步,靠的是改一个收口函数;webRequest 改写让前端零改动接入鉴权网关。能在收口点解决的问题,绝不到处散点改造。
  3. 格式交接即信息损耗。 OCR→文本、JSON→字段、解码→重编码,每一次中间转换都是 bug 和损耗的温床。能直连就别中转,必须中转就零字节透传。
  4. 迁移要做完,读写要成对。 "做了一半的迁移"是最危险的债——系统看似正常,数据却在悄悄丢失。读写两端成对上线 + 历史回填,是迁移的纪律。
  5. 加载时序的 bug 最隐蔽。 代码看着对、单测跑得过,但依赖在加载期固化了引用,后续覆盖对它无效。动态派发是根治思路。
  6. 工具链要按官方姿势用。 ORM、迁移、模块联邦——不按官方推荐用法走,迟早被反噬。

一天,四条主线,十个变更。架构演进往往不是某一次大爆炸式的重写,而是这种持续不断的"把半成品做完、把重复消除、把隐蔽 bug 挖出来"的日常功课。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐