绞杀者模式(Strangler Fig Pattern)的名字来源于一种热带藤蔓植物:种子落在大树上发芽,藤蔓慢慢缠绕树干生长,最终大树枯死,藤蔓取而代之。Martin Fowler在2004年提出这个模式时,正是想借这个意象描述一种安全的遗留系统改造思路——不要一口气推倒重写,而是在旧系统外围一点点生长出新系统,逐步接管功能,最后旧系统自然消亡。这个思路在Agent系统改造场景里尤其有价值,因为Agent系统往往涉及模型调用、工具编排、会话状态等大量历史包袱,直接重写的失败率非常高。

为什么推倒重写在遗留系统改造中几乎总是失败
先说说大爆炸式重写的问题。很多团队在接手一个运行了五六年单体系统时,第一反应是“这代码太烂了,重写一个吧”。听起来干脆利落,实际上隐藏着巨大的风险。第一个问题是需求蔓延:旧系统里堆叠了大量边角逻辑,比如某个客户要求的特殊计费规则、某个历史遗留的兼容处理,这些知识往往只存在于代码里,文档早就过期了。重写团队如果遗漏了这些逻辑,上线第一天就会被业务方打回。
第二个问题是周期失控。重写期间旧系统还在持续接收新需求,团队不得不两边同时维护,开发压力翻倍。而重写项目往往要一年以上,这段时间里业务在变、市场在变,新系统的设计还没上线就已经过时了一部分。第三个问题是风险集中,新旧系统一刀切换的那一天,所有未知的问题会同时爆发,没有退路可言。据统计,大型重写项目的失败率长期居高不下,微软、网景等公司都有过惨痛教训。
绞杀者模式把这些风险拆散了。它不要求一次性替换,而是建立一个门面(Facade)层,把旧系统保护起来,然后按模块、按接口逐步在外围实现新逻辑,通过路由控制把流量一点点切过去。每个切流动作都是小步快跑,出问题就切回来,风险始终可控。对于Agent系统来说,这意味着你可以先从最简单的一个工具调用接口开始替换,验证新的编排引擎没问题后,再去啃会话管理、记忆存储这些硬骨头。
绞杀者模式的核心结构与门面层设计
绞杀者模式的骨架由三部分组成:门面层、新系统、旧系统。门面层是关键,它像一个反向代理,接收所有外部请求,然后根据配置决定这个请求是走旧系统还是新系统。客户端完全感知不到后端的变化,这就给了改造方充分的操作空间。下面是一个基于路由配置的门面实现示意:
class StranglerFacade:
def __init__(self, legacy_router, new_router):
self.legacy_router = legacy_router
self.new_router = new_router
# 路由配置:指定哪些能力已经由新系统接管
self.routing_rules = {
"tool.invoke": "new", # 工具调用已迁移到新编排引擎
"session.manage": "legacy", # 会话管理仍走旧系统
}
def dispatch(self, request):
capability = request.capability
target = self.routing_rules.get(capability, "legacy")
if target == "new":
return self.new_router.handle(request)
return self.legacy_router.handle(request)路由规则最好做成动态配置,支持按能力、按用户分组、按百分比三个维度控制。按能力切流是最粗粒度的,整个功能模块直接切换;按用户分组可以做白名单验证,先让内部员工使用新系统;按百分比则是最细的灰度控制,配合监控指标逐步放量。这三层粒度组合起来,基本能覆盖所有切流场景。
门面层还要承担协议适配的职责。旧系统的接口协议往往比较老,新系统可能用的是gRPC或者消息队列,门面需要把两边对接起来。这里有个实践建议:不要让门面做太重的业务逻辑,它应该保持薄和稳定,只做路由、协议转换和可观测性埋点。一旦门面本身变得复杂,它就成了新的遗留系统,改造就失去了意义。
数据层的双写与一致性处理
系统改造中最棘手的往往不是接口,而是数据。旧系统的数据库已经积累了海量数据,新系统不可能从空库开始。常见的做法是双写:写请求同时写旧库和新库,读请求先全部走旧库,等新库数据追平并且验证一致后,再把读流量切到新库。下面是双写流程的一个简化示意:
class DualWriteService:
def save_message(self, message):
# 先写旧库,旧库是当前的事实来源
legacy_id = self.legacy_repo.insert(message)
# 再异步写新库,失败只记录不阻断
try:
self.new_repo.insert(message)
except Exception as e:
self.log_dsl_fail(legacy_id, message, e)
# 写入补偿队列,后续重试
self.compensation_queue.push(legacy_id)
return legacy_id双写期间最大的挑战是一致性校验。异步写新库必然存在延迟甚至失败,所以必须有一个对账机制,定期比对新旧两个库的数据差异。对账可以是全量的,比如每天凌晨跑一次比对任务,也可以是抽样的,在请求链路上随机抽取一部分记录做实时比对。发现不一致时要区分是延迟还是真正的数据丢失,延迟可以通过补偿队列追平,丢失则需要人工介入排查根因。
另一个要点是新旧库的模型映射。旧库的表结构和新库的领域模型之间往往不是一一对应的,比如旧系统把Agent会话和消息存在同一张大表里,新系统拆成了会话表和消息表两张。这个映射逻辑要写在一个独立的转换层里,并且配上完整的测试用例,因为它是数据迁移正确性的生命线。切忌在双写代码里到处散落if else式的字段映射,那样维护成本会迅速失控。
改造节奏规划与回滚预案
确定改造顺序时有个原则:先切简单且低价值的,再切复杂且高价值的。用业务价值和实现难度做个二维矩阵,优先做那些难度低、价值也不高的模块,用它们练手,验证整个绞杀者基础设施是否可靠。等切流机制、对账机制、回滚机制都经过实战检验后,再去动核心链路。对于Agent系统,一个比较常见的顺序是:先迁移工具调用接口,再迁移模型适配层,最后处理会话与记忆存储,因为后者的数据耦合最深。
每次切流前必须准备好回滚预案。回滚不等于失败,它是切流流程的正常组成部分。预案要具体到操作层面:路由配置一键切回旧系统的开关在哪里,切回后新系统已产生的数据怎么处理,监控告警的阈值如何调整。建议在切流后的二十四小时内安排专人值守,核心指标包括错误率、延迟分位数、业务成功率,一旦指标越界立即执行回滚,不要抱着“再观察观察”的侥幸心理。
最后要提前定义旧系统退役的条件。当所有路由规则都指向新系统、旧系统在一段时间内零流量后,可以先把旧系统设为只读备份,观察一到两个月,确认没有遗漏的调用方后再下线。下线时数据库不要立刻删除,归档封存起来,给可能出现的审计需求留条后路。整个绞杀过程可能持续半年到一年,这不算慢,相比一次失败的大爆炸重写,这个节奏反而最快。
改造中常见的坑与应对经验
第一个坑是隐蔽的调用方。有些下游系统绕过门面直接调用了旧系统的内部接口,你以为已经切走的流量其实还在打旧系统。应对办法是在旧系统的入口全面埋点,统计所有调用来源,对不认识的调用方逐一排查,确认它们都改走门面之后再推进切流。同时要在团队规范里明确写清楚:旧系统不再接受新的直接调用。
第二个坑是共享状态。新旧系统并行期间,如果某些状态(比如缓存、分布式锁)还是共享的,两边的行为会互相干扰,排查起来极其痛苦。原则上新旧系统的运行时状态要完全隔离,各自维护各自的缓存和锁,只通过数据层的双写保持最终一致。第三个坑是改造疲劳。绞杀者模式周期长,团队容易在中后期松懈,把还没迁移的模块一直拖着。建议用明确的里程碑和进度看板管理整个过程,每个阶段设定完成时间点,让“绞杀”始终保持推进的张力,直到旧系统彻底退役为止。