一天的全栈架构升级:检测服务瘦身、多模态识别、本地鉴权网关与数据层标准化
目录
这是一篇工作复盘。一天里我们在一个芯片数据平台(下称项目A)上推进了 10 个变更,横跨前端、桌面端、四个后端服务和一个 Python 检测服务。表面上是零散的功能和 bug 修复,串起来看其实是四条清晰的主线:一条检测链路的彻底重构、一条桌面端鉴权与数据层的标准化、一个困扰已久的 Web 掉线幽灵 bug,以及一次"做了一半的迁移"被补全。
本文不讲流水账,只讲每条主线里值得记下的技术决策和那些隐蔽的坑。
涉及的系统范围
图中所有真实服务名已脱敏为角色名。下面按四条主线展开。
主线一:芯片检测链路的彻底重构
业务线A 下的四个业务模块(业务模块甲、业务模块乙、业务模块丙、业务模块丁)原来都依赖一个臃肿的 检测服务(Python),它把三种性质迥异的能力混在一起:YOLO 板卡检测、PaddleOCR 丝印提取、LangGraph 智能体搜索。一天里我们对这条链路连下三刀。
1.1 服务瘦身:把混合服务拆回单一职责
旧的检测服务有三个痛点:
- 用进程内字典
_sessions维护会话,无 TTL、无持久化、重启即丢、多 worker 不可用; - 跨步骤的
session_id把"检测"和"分析"死死耦合,无法独立演进; - LangGraph + Tavily + langchain 这套 Python 依赖很重,而 Agent 服务(Go)早已有同构能力(主控 Agent + 联网搜索工具,后者本就是 Tavily),属于严重的重复建设。
方案是让检测服务回归单纯的 YOLO 板卡检测职责:
几个关键决策:
- 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 调用:
实现要点:
- 扩展
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 取消,返回值与原同步函数完全一致),四个模块零改动自动转异步。
限流退避的反馈环也保留了:异步任务失败时原样透传错误字符串,现有的限流识别(字符串匹配)和自适应退避语义与同步模式等价。
收获:能力存在 ≠ 被使用。当一处改动能让多个调用方集体受益时,找到那个收口点比逐个改造高效得多。
主线二:桌面端鉴权与数据层
桌面端的 token 管理原来完全依赖前端渲染进程:localStorage.TOKEN 是唯一真值,主进程被动读取,刷新也由渲染进程的 setTimeout 负责。这在 Electron 里是个糟糕的设计——渲染进程被节流、隐藏或关闭时刷新链直接断裂,token 过期后所有下游请求统一 401;渲染进程与潜在的并发刷新还会触发后端的 refresh token 复用检测,误撤销所有会话。
2.1 本地鉴权网关:token 管理大搬家
把 token 管理整体迁到桌面端本地后端(Hono),让 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 用法一直偏离官方推荐,酿成三个具体问题:
- schema 与建表 SQL 双份维护:
schema.ts声明字段类型,connection.ts又手写一份CREATE TABLE。改一处忘另一处就出 bug。 - 类型安全形同虚设:token 服务完全用
db.prepare(rawSqlString)绕过 Drizzle,schema 改字段名编译期不报错、运行时才崩。 - 用不上官方工具链:没有
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 patch 给 isomorphic-fetch 打补丁,把那行 bind(self) 改成动态派发器——每次调用都现读 globalThis.fetch(即包装器装上的版本)。这样无论 mfsu 把它打到哪个 chunk、何时加载,都逃不掉包装器。同时把入口的包装器抽成独立 fetch-patch.ts 模块、在入口第一行 import,并把"定时器主动刷新"这套脆弱逻辑整体删掉,改成纯被动刷新(401 → refresh → 重发)。
收获:模块加载时序的 bug 最难查,因为代码"看着是对的"。当依赖在加载期就固化了某个引用,后续的覆盖对它无效——动态派发是根治这类问题的通用思路。
主线四:产品字段动态化——一个"做了一半的迁移"
这是一个值得所有团队警惕的案例。产品库列表页的列(封装、系列、丝印、认证、生命周期等)一直硬编码在前端,和后端的字段配置体系完全脱节——管理员调整字段,列表毫无反应。这次的诉求是让列表列由后端"产品标识"分组的字段配置动态驱动。
但真正的问题在数据层。排查发现:
- 列表上 AI 审核入库的产品,在动态列和详情弹窗里全部显示
-。 - 根因是一次做了一半的迁移:某次提交移除了入库时"基础字段 → 产品列"的拷贝映射;之后又建立了新模型"字段统一走 ExtFields 数据源"——但只建了读取端(列表列 + Preload),从未建写入端。
- 于是入库链路既不写产品列、也不写 ExtFields,数据全滞留在结构化 JSON 里。读写两端各做了一半,中间断了。
这次的修复分三块,逻辑非常干净:
- 入库补写 ExtFields:自动入库 / 人工审核入库时,按已定义的字段模板,把结构化 JSON 里的值写入扩展字段表。
- 白名单过滤:只写入"已定义且有值"的字段,显式丢弃 AI 自行发现的字段及任何幻觉键,避免脏数据进库。
- 历史数据回填:对已入库但 ExtFields 为空、而源结构化数据有值的产品,做一次性回填。
- 数据库零结构变更:复用既有多态机制,只做数据 INSERT,不新增表/列。
同时把前端那 7 个硬编码列删掉,改为按"产品标识"分组动态生成列、从 ExtFields 读值;后端列表接口补一行 Preload("ExtFields")。
收获:迁移最容易翻车的地方,是"读端先迁、写端忘了迁"的半成品状态——数据看似能跑,实则悄悄丢失。纪律是:读写两端必须成对上线,并且要有"历史数据回填"这一步兜底。另外,AI 产出的字段一定要过白名单,幻觉键绝不能直接落库。
附:一个小体验补丁
AI 审核阶段内部会触发"重提取"(自动修复提取错误的字段),但用户完全感知不到——看不到"AI 正在修",也看不到结果,于是看到审核不通过就盲目点"重新提取",而此时 AI 其实已经在背后处理了。补了一个最小的状态展示:新记录重置状态字段,前端在校验结果列增加"重提取中"分支。改动很小,但解决了"用户重复触发本已在跑的任务"的体验问题。
复盘与收获
把这一天的 10 个变更按主线串起来,几条共性的教训值得记下:
- 能力存在 ≠ 被使用。 异步协议、多模态能力、字段配置体系——好几个变更的本质都是"把已经实现但没接出来的能力补上最后一公里"。定期审视"已有但闲置"的能力,往往比新建更划算。
- 找到收口点,四两拨千斤。 四个业务模块转异步,靠的是改一个收口函数;webRequest 改写让前端零改动接入鉴权网关。能在收口点解决的问题,绝不到处散点改造。
- 格式交接即信息损耗。 OCR→文本、JSON→字段、解码→重编码,每一次中间转换都是 bug 和损耗的温床。能直连就别中转,必须中转就零字节透传。
- 迁移要做完,读写要成对。 "做了一半的迁移"是最危险的债——系统看似正常,数据却在悄悄丢失。读写两端成对上线 + 历史回填,是迁移的纪律。
- 加载时序的 bug 最隐蔽。 代码看着对、单测跑得过,但依赖在加载期固化了引用,后续覆盖对它无效。动态派发是根治思路。
- 工具链要按官方姿势用。 ORM、迁移、模块联邦——不按官方推荐用法走,迟早被反噬。
一天,四条主线,十个变更。架构演进往往不是某一次大爆炸式的重写,而是这种持续不断的"把半成品做完、把重复消除、把隐蔽 bug 挖出来"的日常功课。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)