大概半年前,我们做了一个在外人看来有点"败家"的决定:把一个已经上线运行一年多、攒了几十家付费客户、代码量接近十万行的 AI 获客工具,整体推倒重写,变成一个开源的 Web AI 交互层。当时团队的反对声不少,产品同学直接问我:"客户要的是能带来线索的机器人,你要给人家一个开发框架?"这句话听着扎心,但恰恰戳中了原项目的命门——我们其实一直没有一个清晰的定位。
这篇文章想讲的,不只是"我们重写了什么",更是"我们为什么必须重写"。如果你正在做 AI 应用、正准备开源自己的项目,或者纠结于"手里这套代码到底要不要重构",这里面的思考过程和踩坑记录,应该能帮你省下一些弯路。
1. 旧版获客工具的问题清单:我们到底在哪些地方忍不下去了
先交代一下旧项目的背景。我们最早做的是一套 AI 获客工具,核心功能是让企业把聊天机器人挂到微信、企业微信、网页上,自动做售前答疑、线索筛选和人工交接。从功能上看,它确实能跑,客户也有续费,但我心里清楚,这套东西的架构已经快撑不住了。
1.1 业务逻辑和模型能力被绑死在同一个代码库里
旧项目用的是 Python FastAPI,代码结构扁平得一塌糊涂。会话状态、线索清洗规则、话术模板、模型调用 API、数据库访问,全部揉在一个目录里。客户要新增一个 IM 渠道,我们得同时改路由层、消息解析层、会话状态层,任何一个地方漏了,线上就出问题。
最典型的一个场景:某客户希望机器人先判断用户是不是高意向线索,再决定要不要转接人工。这个逻辑听起来很简单,但在旧架构里,它横跨了消息入口、意图分类、线索评分、转接调度四个模块,每个模块之间靠全局变量传状态。有一次为了给一个客户加"地域判断"规则,我们不小心影响到了另一个客户的话术匹配,对方直接投诉到 CEO 那边。这种耦合带来的隐性成本,远比我们以为的高。
1.2 模型接入方式写死,换模型等于伤筋动骨
旧版代码里,对模型供应商的调用是散落在各业务函数里的。当时接了三家模型服务商,每一家的参数格式、流式返回结构、限流策略都不一样,于是代码里到处都是if provider == "A"然后又elif provider == "B"这样的分支。
最初我们只接一家时还好,但后来发现不同模型在不同场景下表现差异极大——有的适合做意图识别,有的擅长长文本总结,有的响应速度快适合闲聊。我们想把"哪个模型处理哪个环节"做成可配置的,结果发现每做一次模型切换,都要翻出核心业务代码来改,测试一遍全量回归,上线后还要胆战心惊盯几天。模型接入和业务逻辑强耦合,导致我们根本不敢尝试新模型,这在 2024 年那个模型能力快速迭代的背景下,等于自断后路。
1.3 Web 端被当成二等公民,体验一直没做对
旧产品的主战场是 IM 渠道,Web 聊天界面是后面临时补的——用 iframe 嵌一个页面,样式跟主站完全脱节,刷新加载慢,长连接经常断。但过去一年我们发现了一个趋势:越来越多的客户想在官网放一个 AI 助手,而不是只放在 IM 里。官网访客没有微信/企业微信好友关系,他们打开页面就想直接问问题,对加载速度、视觉一致性、移动端适配的要求比 IM 渠道高得多。
我们曾经花了两周去优化 Web 端的长连接稳定性,结果发现根源是 WebSocket 服务没有做心跳保活和断线重连。这种问题在 IM 渠道里不突出,因为微信那边会自己维护连接;但浏览器端的网络环境复杂得多,用户锁屏、切换 WiFi、后台休眠都会断开连接。旧架构里 Web 端是后补的,底层设计根本没考虑这些场景,修起来牵一发动全身。
1.4 配置体系薄弱,运营改一句话都要提工单
旧项目的提示词、话术模板、知识库关联规则,全部写在配置文件和数据库表里,但管理界面极其简陋。运营人员想给机器人加一句欢迎语,都得在群里@我们开发,我们改了再发版。有一次客户的活动页需要临时改一套话术,我们排期排了三天,活动都结束了话术才上线。
更难受的是,很多企业客户根本理解不了"提示词"这个概念。你让他自己写一段 prompt,他写出来的东西往往质量很差。这说明一个问题:产品不够面向非技术用户,配置复杂度全在代码层。我们明明做了一个 AI 产品,却让用户连最简单的调整都要依赖工程师,这不是产品该有的样子。
这些问题积累了大半年之后,我们开了一次复盘会。讨论的结论出乎所有人意料:不是"怎么修补",而是"要不要推倒重写"。
2. 重写前的定位推演:"AI 交互层"到底解决什么问题
"推倒重写"这四个字,在工程领域一向被视为禁忌。没有哪个成熟的工程师会轻易支持重写,因为业务在跑、客户在等、时间在烧。但那次会上,我们想明白了一件事:旧项目的天花板不在代码质量,而在产品定位。
2.1 从"帮客户获客"到"让客户自己接入 AI"
获客工具的逻辑是:企业提供话术、知识库、线索规则,机器人去执行,然后把线索捞回来。这个逻辑本身没问题,但它把应用场景锁死了——只有"获客"这一件事。而实际上,企业里需要 AI 交互的地方太多了:售前咨询、售后客服、内部知识问答、HR 面试筛选、技术支持分类,每一个场景的交互模式和数据结构都不一样。
如果我们继续做"获客工具",我们就永远是一家做漏斗的 SaaS 公司,客户群有限、客单价有限、想象力也有限。但如果把它重构成一层"AI 交互层",定位就完全变了:我们不再替客户定义"你的 AI 应该用来做什么",而是提供一个基础设施,让客户自己决定怎么接、怎么用。
一句话概括新定位:模型给你,编排逻辑给你,Web 交互组件给你,你只需要接上自己的业务数据和应用场景。
2.2 为什么优先级最高的是 Web,而不是继续深耕 IM
渠道选择上,我们把 Web 提到了最高优先级,这个决策当时有争议。IM 渠道看似流量大,但开发成本极高——每个平台的消息协议、回调机制、审核要求都不一样,而且很多平台的机器人能力是受限的。
Web 的优势在于"通用":
- 分发成本最低:一个
<script>标签嵌进去,任何网站都能拥有一个 AI 助手,不需要通过平台审核。 - 浏览器能力完整:WebSocket 长连接、Service Worker 离线推送、IndexedDB 本地缓存、多媒体输入输出,该有的都有。
- 与企业现有系统融合容易:绝大多数企业的客服后台、CRM、工单系统都是 Web 应用,交互层作为 Web 组件可以嵌入任意系统,而不是要求用户去另一个 IM 里使用。
说白了,Web 是 AI 助手的"最大公约数"。我们不可能为一个 IM 平台写一套交互层,那样又回到了旧版的老路。
2.3 开源是护城河,不是慈善
定位为"交互层"之后,代码要不要开源的争论就变得很自然了。如果我们是卖获客工具,开源等于自毁商业;但如果我们做的是通用基础设施,闭源反而会增加信任成本——企业凭什么是把你的 SDK 嵌到官网里?它怎么知道你的代码有没有在后台上传它的用户数据?
开源解决了这个问题。代码放出来,安全团队可以自己审计;同时也降低了集成门槛:开发者不再需要先注册你家的控制台、申请 API Key、看冗长的文档,他可以先 clone 下来本地跑通,再决定要不要用你的托管服务。
护城河从来不是靠藏着代码建立的,而是靠生态、兼容性、社区贡献和"行业默认选择"建立的。
这个定位想清楚之后,重写就有了骨架。我们停下来,花了三周把架构重新设计了一遍,然后才开始动工。
3. 交互层架构落地的四个核心模块
新项目用 TypeScript 全栈,前端 React,后端 Node.js(NestJS),数据库 PostgreSQL + Redis。技术选型的原因后面细说,先讲四个核心模块的设计思路,它们是整个交互层的支柱。
3.1 模型网关:统一抽象,避免被单一供应商锁死
模型网关是整套系统的地基。不管后面接多少个模型,业务层永远只面对一个统一的接口。
// 统一模型适配器接口 interface ModelAdapter { chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse>; stream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<ChatChunk>; } // OpenAI 兼容协议适配器 class OpenAICompatibleAdapter implements ModelAdapter { async chat(messages: ChatMessage[], options?: ChatOptions) { // 调用任何支持 OpenAI 兼容协议的服务 } async *stream(messages: ChatMessage[], options?: ChatOptions) { // 流式返回 } } // 本地模型适配器(vLLM / Ollama 等) class LocalLLMAdapter implements ModelAdapter { // ...实现各自的流式协议 }核心思想是适配器模式。业务层只依赖ModelAdapter这个抽象接口,具体走哪家模型,由配置文件和环境变量决定。这样做的直接收益是:我们可以在不同模型之间自由切换,同一个会话中用户问"产品报价",可能走的意图识别模型 A;用户问"售后政策",可能走的对话生成模型 B,而业务代码一行都不用改。
模型网关还承担了另外两个职责:一是统一限流和重试策略,不同模型的速率限制差异很大,在网关层统一做指数退避和熔断,比在业务层到处补异常处理优雅得多;二是统一日志和可观测性,每个请求的模型名、token 消耗、延迟、错误码都打成结构化日志,方便后期做成本分析和质量监控。
3.2 会话与上下文管理:token 预算才是对话质量的关键
做 AI 助手的同学都有体会:模型本身能力差距其实没那么大,真正拉开体验差距的是你怎么把上下文喂给它。旧项目按消息条数硬截断,只保留最近 20 条,超出就丢掉,导致模型经常"失忆",回答质量堪忧。
新架构里,我们建立了严格的 token 预算体系:
| 上下文组成部分 | 预算分配(以 32k 窗口为例) |
|---|---|
| 系统提示词 | 4k tokens |
| 工具/函数定义 | 2k tokens |
| 可用的对话历史 | 20k tokens |
| 预留输出空间 | 6k tokens |
对话历史的组织不再按条数,而是按 token 预算动态裁剪。核心逻辑是:优先保留最近的对话,最早的消息如果超出预算,则用一段摘要替换,而不是直接丢掉。
function buildContext(conversation: Message[], budget: number): Message[] { let tokens = 0; const result: Message[] = []; // 从最近的用户消息开始往回拼装 for (let i = conversation.length - 1; i >= 0; i--) { const msg = conversation[i]; if (tokens + msg.tokens > budget) { break; } result.unshift(msg); tokens += msg.tokens; } // 如果还有剩余空间,把最早的几条合并成摘要 if (tokens > budget * 0.7 && conversation.length > 10) { const summary = generateSummary(conversation.slice(0, 5)); return [summary, ...result]; } return result; }这里有一个容易被坑的细节:不同模型的 token 计算方法不一样。有的模型用 BPE 分词,有的用字符级 token,直接用len(text) / 4去估算会导致预算严重失真。我们的做法是让模型网关在适配器层返回真实的 token 计数,业务层只认这个值,避免各模块自己算导致的口径不一致。
3.3 插件/工具调用机制:交互层最重要的扩展点
如果模型网关是地基,插件机制就是承重墙。企业在自己的场景里接入 AI 助手,十有八九需要让它调用现有系统——查订单、查库存、创建工单、查询 CRM 记录。这些能力不可能由我们内置,必须通过插件暴露出去。
插件机制的实现借鉴了 Koa 的洋葱模型:
app.use(async (ctx, next) => { // 请求前钩子:可以改写用户上下文、注入系统信息 ctx.state.injectDate = new Date().toISOString(); await next(); // 响应后钩子:可以改写模型输出、记录日志 });每个插件就是一个中间件函数,可以在请求发出前修改上下文,拿到模型结果后再做后处理。比如某个客户想让机器人回答之前自动查一下公司最新的产品库存,他只需要写一个插件,在请求前调用库存接口,把结果注入到系统提示词里,模型就知道了最新库存状态。
这里必须提醒一句:插件机制越强大,安全边界越要谨慎。我们最初的设计过于开放,允许插件代码在服务进程内任意执行,结果在做安全测试时发现,一个被投毒的第三方插件完全可以读取服务器上的任意文件。后来我们改了设计:插件只能通过声明式的 JSON Schema 定义工具参数,运行时把工具调用放在独立的沙箱进程中执行,并对每个插件的文件系统、网络、环境变量访问做好权限隔离。
3.4 前端 Web Chat Widget:可嵌入是传播的前提
模型网关和会话管理再好,用户接触到的还是那个聊天气泡。所以我们把前端 Chat Widget 当成一个独立产品来做,而不是"顺便写个页面"。
它的核心要求只有一个:任何网站,加一行脚本就能跑起来。
<script src="https://cdn.example.com/chat-widget.js">