在 ai-voice-platform 从最初的单点呼入验证,逐步演进为涵盖 实时语音底座(callflow-esl)、纯 Rust 本地语音推理集群(onnx-platform) 以及 智能外呼中台(callout-server) 的全栈系统后,功能虽然全部跑通了,但代码可读性已经彻底崩盘。

很多时候排查线上异常,我看着屏幕上的实现,甚至会陷入沉思:“这段代码到底是谁写的?为什么要绕这么大一圈?”

早期为了快速推进业务,加上自己有些盲目的“技术洁癖”,在工程里留下了大量低效、晦涩的防御性代码。当时总觉得“既然有 AI 帮写,繁琐一点无所谓”。但随着项目规模膨胀,基底没有打好,AI 只会在原有的坏味道上继续堆砌废码。最近核心能力逐渐收敛,我决定暂缓新需求开发,花时间把这笔欠下的技术债一次性还清。


一、破除“零依赖洁癖”:手搓轮子往往带来更大的维护代价

平台里的三个 TypeScript 后端服务均基于 Bun 运行。

项目初期,我一度陷入了典型的“极简主义”偏执:觉得 Bun.serve 原生性能出众,API 足够直观,何必再引一层 Web 框架?于是带着 AI 手写了一套基础的 router.ts——用正则匹配 URL,用 if-else 分发路由,并在每个 Handler 内手工提取参数与捕获异常。

在接口数量较少时,干净的依赖列表确实让人安心。但当接口规模突破上百个,引入复杂的 RBAC 权限与内部微服务鉴权后,这种原始做法直接带来了灾难:

  1. 样板代码掩盖核心业务:每个 Handler 开头都充斥着正则捕获、typeof 手动类型断言与判空兜底,稍有疏漏就会引发运行时未捕获异常。
  2. 通用逻辑散落全工程:CORS 跨域、耗时统计、用户鉴权以及内部微服务通信凭证(X-Callout-Internal-Token)分散在各个接口内部,改动一次规范需要通篇全局替换。
  3. 异常处理机制脆弱:各处重复编写 try-catch,一旦漏写,未拦截的异常直接破坏了统一的响应格式。

本次重构的第一步,就是果断引入成熟的基础设施,将三个 HTTP 服务统一迁移至 Hono + Zod:

1
2
3
4
5
6
7
8
flowchart LR
Req[HTTP 请求] --> Logger[耗时日志 & CORS 中间件]
Logger --> Err[顶层全局异常兜底 onError]
Err --> Auth[统一身份 / Token 校验]
Auth --> Zod[Zod 强类型参数校验]
Zod --> Handler[纯净的业务逻辑]
Handler --> Resp["标准响应信封 { code, data, msg }"]

引入成熟框架后,代码质量有了质的改善:

  • 请求参数在进入业务层前,已被 Zod 完成结构校验与类型收窄,业务层彻底移除了断言与 any;
  • 横切关注点(鉴权、日志、跨域)收敛至顶层中间件管道;
  • 业务逻辑只需关注正常流程,出现异常直接抛出,由顶层统一拦截并包装成标准化响应信封。

依赖少,不代表架构干净。
框架的价值在于将协议解析、参数校验和异常熔断消化在底层管道中。如果为了追求形式上的零依赖,逼着业务层承担成倍的手工防御代码,这属于典型的本末倒置。


二、摒弃 RESTful 教条,落地扁平的 Action-Oriented RPC

在早期开发中,AI 习惯性地生成了大量层级嵌套的 RESTful 接口:

  • PATCH /api/v1/scripts/:scriptId/nodes/:nodeId/prompt(更新话术节点 Prompt)
  • DELETE /api/conference-sessions/:conferenceId/participants/:softphoneCallId(剔除会议成员)

这类设计在理论上符合资源模型,但在高频状态流转的语音业务中,维护成本极高:

  1. 参数分散在三处:一个请求的参数同时存在于 URL 路径(param)、查询字符串(query)和包体(json)中。后端无法通过单一结构体一次性完成校验。
  2. 前端调用频繁拼接字符串:前端代码充斥着模板字符串与 encodeURIComponent,存在隐蔽的拼接错误隐患。
  3. 运维与日志检索困难:动态参数内嵌在 URL 中,导致全链路监控无法直接按路由统计接口 QPS 与耗时,必须编写规则正则提取模板。

语音中台的核心本质是“业务动作与状态调度”。我索性抛弃层级资源路径,在全平台推行纯静态扁平化的 RPC 规范:

场景分类 请求方式 路由与传参规范 典型示例
无参 / 简单只读 GET 静态路径(Query 标量 ≤ 3 个) GET /api/dashboard/overview
SSE 实时流 GET 静态路径 + 简单 Query GET /api/softphone-sessions/events?sessionId=...
列表与复杂检索 POST 静态路径(/list、/detail)+ JSON 体 POST /api/campaigns/list
数据变更与业务调度 POST 静态路径(明确动作动词结尾)+ JSON 体 POST /api/scripts/node-prompt-update

全平台严格禁止在 URL 路径中传递动态参数(/:id)。

规整后,前端的接口调用退化为“静态常量路径 + 强类型 JSON”;后端每个 Handler 严格映射一个确定的 Zod Schema;URL 自身即为全局唯一的动作标识(Action),链路追踪与统计一目了然。

规范服务于人,而非人服务于规范。
当一套范式需要大量繁琐的适配胶水才能运转时,就应主动寻找更适合当前业务场景的解法。扁平、低歧义、特例少的接口,才是最利于长期维护的契约。


三、明确架构边界:分层隔离与单向依赖

仓库内同时包含底层的 FreeSWITCH 媒体流处理、Rust 语音推理、外呼中台与前端控制台。为了防止不同层次的逻辑互相侵蚀,我建立了三道刚性约束:

1. 通信底座与调度中台物理解耦

  • 语音底座(callflow-esl):专注于通信通道与媒体流传输。依托 mod_audio_fork 稳定收发 16kHz PCM16 音频流,执行毫秒级开嗓打断(Barge-in),以及与 Rust 推理集群交互。
  • 调度中台(callout-server):完全隔离底层媒体细节,只负责拨号任务调度、三栏话术状态流转、大模型决策组装与人工坐席协同。

两个子系统间仅依靠扁平 RPC 交互,底座媒体传输协议的调整对中台业务透明。

2. 通过 Facade 模式约束底层操作

在 callflow-esl 内部,承载了二十余种不同的呼叫分支。为防止上层业务误操作底层通信逻辑:

  • 封装顶层门面 CallContext,业务逻辑只能调用受控的上下文方法(如 ctx.speak、ctx.hear、ctx.conference);
  • 严禁业务逻辑直接调用底层 ESL Socket;
  • 会话生命周期统一由外层 SessionRunner 接管,异常断连与资源回收完全由框架兜底;
  • 底层配置模块禁止逆向依赖上层业务模块,从工程约束上消除循环依赖。

3. 将决策判定提纯为纯函数

针对复杂的路由鉴权推导、状态流转判定等逻辑,全部剥离出框架上下文(脱离 Hono Context、Router 实例及数据连接池),重构为零副作用的纯函数:输入确定数据,输出确定决策。这不仅大幅降低了代码理解成本,也让单测覆盖变得非常轻量。


结语

完成这次重构后,系统表面上并没有新增任何华丽的功能,也没有改变任何对外展示的业务流程。

但工程的底层骨架已经完全不同。更实际的收益在于:当底层的约束、类型定义与分层边界被明确固化后,后续由 AI 协助生成的新功能代码,会自动遵循这套规范演进,不会再肆意泛滥出失控的样板与坏味道。

只有技术基础设施保持克制与规整,业务代码的维护与迭代才能真正进入良性循环。

站内搜索

没有找到内容!