1. 项目概述:当重构成为一场“外科手术”

“重构”这个词,在软件开发领域,听起来总带着点英雄主义的色彩,仿佛一位工程师单枪匹马,就能把一座年久失修、结构混乱的“代码屎山”推倒重来,建立起一座崭新的、优雅的摩天大楼。然而,但凡在一线摸爬滚打过几年的老手,听到要对一个“复杂系统”进行“一把梭重构”,后背都会冒出一阵冷汗。这感觉,就像医生面对一个全身多器官衰竭、但每天还必须维持心跳和呼吸的病人,你说要给他做个“全身器官一次性大换血”,这无异于一场豪赌,赌注是整个系统的生命线和业务连续性。

我最近主导的一个项目,核心就是一个典型的“复杂系统”——一个运行了超过五年的智能研究辅助平台,内部我们称之为“Semi-Autoresearch”系统。这个名字本身就揭示了它的复杂性:“Semi”意味着它并非全自动,大量环节依赖研究员的经验判断和人工干预;“Auto”部分则是由一系列历史遗留的脚本、规则引擎和早期机器学习模型拼凑而成。系统模块间耦合深如马里亚纳海沟,数据流像一团被猫玩过的毛线球,文档?那基本是考古学范畴的东西了。业务方提出的需求是:引入现代化的AI Agent框架,提升自动化研究能力,并改善系统可维护性。这本质上就是一次大规模重构。

但我们没有选择“一把梭”。因为“一把梭”意味着漫长的开发周期(期间业务需求不会停止)、极高的回归测试成本、无法预知的数据迁移风险,以及最可怕的——在某个深夜上线后,发现某个边缘但关键的功能悄无声息地消失了,导致第二天整个研究团队的工作陷入瘫痪。我们的策略是“小步迁移”,像外科医生做微创手术一样,在保证病人(系统)生命体征平稳的前提下,逐块切除病灶,植入新的“器官”(模块),最终实现系统的平稳演进。今天,我就来详细拆解我们是如何实施这场“外科手术式”重构,确保每一步都走得稳,功能一个不掉。

2. 重构前的深度诊断与方案设计

在动任何一行代码之前,充分的诊断和设计比编码本身更重要。对于复杂系统,盲目动手就是灾难的开始。

2.1 系统“体检”:绘制现状地图

我们的第一步不是讨论新技术,而是彻底理解旧系统。我们组建了一个“考古小队”,花了大约两周时间,做了以下几件事:

  1. 动态跟踪与静态分析结合 :我们不仅看代码,更观察运行时。通过添加详细的日志埋点(非侵入式,通过AOP或中间件),绘制出关键业务场景下,请求在各个服务、模块间的流转路径。同时,使用代码分析工具生成模块依赖图、类关系图。两相对照,就能发现很多文档上没有的“隐藏通道”和“秘密合约”。
  2. 识别功能边界与数据契约 :和业务方、老员工一起,梳理出系统的核心功能清单。每一个功能,我们都追问:它的输入是什么?(哪张表、哪个API、什么格式的文件)它的处理核心逻辑是什么?(哪怕是一段晦涩的SQL或Python脚本)它的输出又是什么?(生成报告、更新状态、调用下游服务)。我们把这些输入输出定义为“数据契约”,这是后续迁移过程中必须严格遵守的“宪法”。
  3. 评估技术债与风险点 :明确哪些部分是“脓包”(如使用已无人维护的第三方库、存在严重性能瓶颈的算法),哪些是“骨骼”(如核心的数据模型、基础的用户认证授权体系),哪些是“肌肉”(业务逻辑)。脓包需要优先处理或隔离,骨骼要谨慎对待,肌肉可以逐步替换。

实操心得 :这个阶段会非常枯燥,但价值连城。我们建立了一个“系统知识库”,用图表和文档记录下每一个发现。一个关键技巧是: 为每一个识别出的“数据契约”接口,编写一个最简单的、可独立运行的验证脚本 。这个脚本不关心内部逻辑,只验证给定输入能否得到预期输出。这将成为后续迁移过程中,验证功能一致性的“黄金标准”。

2.2 制定迁移策略:绞杀者模式与并行运行

基于诊断结果,我们决定采用经典的“绞杀者模式”作为总体战略,并辅以“并行运行”作为核心战术。

  • 绞杀者模式 :不直接修改旧系统,而是在其外围构建新的服务(新Strangler)。新的功能需求或对旧功能的修改,全部导向新服务。旧系统(被绞杀者)的功能逐渐被新服务替代,直到最终其流量降为零,可以安全退役。
  • 并行运行与流量切换 :这是保证功能不掉的关键。对于任何一个准备迁移的功能模块,我们不是直接替换它,而是:
    1. 在新架构中实现一个功能完全等价的新模块。
    2. 部署新模块,并让它和旧模块同时运行。
    3. 引入一个“流量开关”(Feature Flag或路由配置),将少量、可控的流量(比如内部测试用户的请求)导入新模块。
    4. 严密监控新模块的输出,与旧模块的输出进行比对(利用之前准备的“黄金标准”验证脚本),同时监控系统指标(错误率、延迟、资源消耗)。
    5. 确认新模块稳定、结果一致后,逐步调大流量比例,从1%到5%,再到50%,最后100%。一旦在某个比例发现问题,可以瞬间将流量切回旧模块。

这个策略的核心思想是: 将“发布”和“启用”解耦 。新代码随时可以部署上线(发布),但只有通过开关控制,才真正处理生产流量(启用)。这给了我们巨大的安全感和回滚能力。

2.3 技术选型:新旧体系的桥梁

我们的目标是引入AI Agent框架。市场上框架很多,但选型时我们特别考虑了与旧系统的融合能力:

  1. API-First设计 :我们选择的Agent框架必须能够通过清晰的API(如HTTP、gRPC)对外提供服务,方便旧系统的其他模块调用。这避免了必须将整个旧系统重写才能集成新能力的困境。
  2. 状态外置 :复杂的Agent往往有内部状态(记忆、任务栈)。我们要求框架支持将状态存储到外部数据库(如Redis、PostgreSQL),这样即使Agent实例重启,状态也不丢失。更重要的是,这为 新旧系统共享Agent状态 提供了可能。例如,旧系统的一个步骤可以设置一个任务状态,新系统的Agent可以读取并继续执行。
  3. 可观测性 :框架必须提供强大的日志、指标和追踪能力。我们需要清楚地知道一个研究任务在Agent内部是如何被分解、执行、决策的,这对调试和验证结果一致性至关重要。

基于这些考虑,我们最终选择了一个轻量级、可插拔的Agent框架,并对其进行了二次封装,使其对外暴露为一组标准的RESTful服务,并对接公司的统一监控平台。

3. 小步迁移的核心实操流程

理论说再多,不如看看我们具体怎么走一步。这里以迁移一个“文献自动摘要”功能为例。旧实现是一个巨型的Python脚本,里面混杂了文本提取、规则过滤和一个古老的LDA主题模型。

3.1 第一步:建立防腐层与契约测试

我们没有直接去重写那个脚本。首先,我们在旧脚本的调用方和新规划的服务之间,建立了一个“防腐层”(Anti-Corruption Layer, ACL)。在微服务架构中,ACL常用来隔离不同限界上下文,在这里,我们用它来隔离“旧世界的混乱”和“新世界的秩序”。

具体做法是,创建一个新的轻量级服务(或一个模块),它对外提供一个干净的接口,比如 POST /api/v1/summarize 。这个服务内部, 第一次迭代,它什么都不做,只是作为一个代理,去调用那个旧的Python脚本 (可能通过子进程调用或RPC)。

# 防腐层服务示例 (伪代码)
@app.post('/api/v1/summarize')
def summarize_endpoint(request: SummaryRequest):
    # 1. 请求验证与适配(将新API格式转为旧脚本需要的格式)
    old_style_input = adapt_request(request)
    
    # 2. 调用旧系统核心功能
    # 注意:这里可能通过封装好的客户端、命令行调用或直接导入函数实现
    old_result = call_legacy_summarize_script(old_style_input)
    
    # 3. 响应适配与返回(将旧脚本输出转为新API格式)
    new_style_output = adapt_response(old_result)
    return new_style_output

def call_legacy_summarize_script(input):
    # 示例:通过子进程调用。确保环境隔离。
    import subprocess
    result = subprocess.run(
        ['python', 'legacy_summarizer.py', '--input', input.json()],
        capture_output=True,
        text=True
    )
    if result.returncode != 0:
        raise LegacySystemError(result.stderr)
    return json.loads(result.stdout)

同时,我们为这个新的 /api/v1/summarize 接口编写了完整的契约测试。测试用例来源于对旧脚本的大量历史调用记录和边界值分析。 这个阶段,所有测试都必须通过,因为实现逻辑就是旧的 。这一步的目标是确立一个“可信的基准”。

3.2 第二步:并行实现与影子测试

接下来,我们开始用新的AI Agent技术实现摘要功能。新的Agent可能利用最新的Transformer模型(如BART、T5),具备更好的理解和生成能力。我们实现一个新的内部组件 NewSummarizerAgent

关键来了: 我们不立即替换防腐层里的调用 。我们在防腐层服务里添加一个“影子模式”开关。当开关开启时,对于每一份流入的请求,防腐层会:

  1. 正常调用旧脚本,并返回结果给用户(保证线上功能)。
  2. 异步地 将同一份请求发送给新的 NewSummarizerAgent
  3. 在后台对比新旧两个结果,并将对比详情(输入、旧输出、新输出、差异度指标)记录到专门的日志或数据库中。
# 带影子模式的防腐层
@app.post('/api/v1/summarize')
def summarize_endpoint(request: SummaryRequest):
    old_style_input = adapt_request(request)
    
    # 主路:始终调用旧系统,保证稳定
    old_result = call_legacy_summarize_script(old_style_input)
    primary_response = adapt_response(old_result)
    
    # 影子路:异步调用新实现,进行对比
    if settings.SHADOW_MODE_ENABLED:
        # 异步执行,不阻塞主请求
        background_tasks.add_task(
            run_shadow_comparison,
            request_data=request.dict(),
            old_result=old_result
        )
    
    return primary_response

async def run_shadow_comparison(request_data, old_result):
    try:
        # 调用新Agent
        new_result = await new_summarizer_agent.run(request_data)
        # 计算差异(如ROUGE分数、关键信息覆盖度)
        diff_score = calculate_difference(old_result, new_result)
        # 记录到影子日志
        shadow_logger.info({
            'input': request_data,
            'old_output': old_result,
            'new_output': new_result,
            'diff_score': diff_score,
            'timestamp': datetime.now()
        })
    except Exception as e:
        # 影子路径出错不能影响主路
        shadow_logger.error(f"Shadow path failed: {e}")

我们让这个影子模式在生产环境运行至少一到两周,收集足够多的对比数据。通过分析这些数据,我们可以回答:新模型在99%的情况下和旧结果一样好吗?在哪些类型的文献上表现更好或更差?它的平均响应时间是多少?资源消耗如何?

3.3 第三步:流量切换与功能验证

基于影子测试的数据,我们对新实现的信心大增。现在,我们开始真正的切换。我们使用功能开关(Feature Flag)来控制流量。

  1. 金丝雀发布 :将内部测试团队或一小部分可信用户的流量(通过用户ID或请求特征标记)路由到新实现。防腐层收到这些标记的请求后,直接调用 NewSummarizerAgent ,并彻底绕过旧脚本。同时,我们加强监控,不仅监控接口成功率、延迟,也通过抽样方式,人工抽查新生成摘要的质量。
  2. 逐步扩大 :如果金丝雀阶段表现稳定,我们将流量比例逐步提升到5%,10%,25%……每次提升后,稳定观察一段时间(至少几个小时,观察业务高峰期的表现)。
  3. 最终切换与旧代码清理 :当100%的流量都切换到新实现,并且稳定运行超过一个完整的业务周期(例如一周)后,我们就可以着手清理了:
    • 移除防腐层中对旧脚本的调用代码。
    • 下线旧的Python脚本及其相关依赖。
    • 移除影子模式的相关代码。
    • 最终,这个“文献自动摘要”功能就完成了从旧系统到新Agent架构的平滑迁移。旧的实现被“绞杀”,新的服务完全接管。

4. 迁移过程中的典型挑战与应对策略

这个流程听起来顺畅,但实际操作中坑无处不在。下面是我们遇到的一些典型问题及解决办法。

4.1 数据一致性与状态管理难题

问题 :有些功能不是无状态的纯函数。比如,一个“研究任务跟踪”Agent,它需要记住之前已经分析了哪些论文,当前处于什么步骤。旧系统可能把这些状态放在自己的数据库表里,或者甚至放在内存中。迁移时,如何保证新旧模块看到的状态是一致的?

解决方案 :我们引入了“状态共享层”。在迁移这类有状态功能前,我们先进行重构,将旧系统的状态存储方式,迁移到一个中立、新旧系统都能访问的存储中(如Redis或一个独立的PostgreSQL表)。这个过程本身也是一次小规模迁移。

  1. 先修改旧系统,在更新状态时, 同时 写入旧的存储和新的共享存储(双写)。
  2. 运行一段时间,验证双写的一致性。
  3. 修改旧系统,改为 只从共享存储读取状态 ,写入仍保持双写。
  4. 验证读取无误后,修改旧系统,状态更新也改为只写共享存储。至此,旧系统的状态管理已完全依赖共享层。
  5. 现在,新实现的Agent就可以安全地读写同一个共享存储,从而与旧系统在状态上保持同步。迁移完成后,可以再考虑优化共享存储的结构。

4.2 非功能需求的保障

问题 :旧脚本可能性能很差,但大家习惯了;新Agent用了大模型,延迟更高。如何确保迁移不导致用户体验下降?

解决方案 :将非功能需求(性能、可用性、资源消耗)明确纳入契约测试和影子对比的范畴。

  • 性能基准 :在影子测试阶段,我们就严格监控新服务的P95/P99延迟、吞吐量。如果发现新服务延迟超标,我们不会急于切换流量,而是先进行优化,例如:
    • 为Agent引入缓存机制,对相同或相似的文献摘要进行缓存。
    • 使用模型量化、蒸馏等技术减小模型体积,提升推理速度。
    • 采用异步处理,对于非实时性要求极高的摘要任务,改为队列处理,并通过WebSocket或回调通知用户。
  • 资源监控 :我们为新的Agent服务设置了严格的资源限额(CPU、内存)和告警。确保它不会因为某个异常请求耗尽资源,影响到部署在同一环境下的其他服务。

4.3 团队协作与知识传递

问题 :迁移是一个长期过程,如何避免只有一两个人掌握全局,形成新的知识瓶颈?

解决方案

  1. 文档即代码 :我们将“系统知识库”、“数据契约定义”、“迁移路线图”都放在版本控制(如Git)中,随着迁移进度一起更新。每一次迁移决策的背景、考虑因素,都在代码Review或设计文档中写明。
  2. 轮流主导 :每个迁移子任务(如“摘要迁移”、“数据收集迁移”)由不同的工程师配对主导。负责的人需要深入理解新旧两套逻辑,并编写最终的切换方案。这迫使知识在团队内扩散。
  3. 复盘文化 :每一个功能模块完成迁移并稳定运行后,我们都会进行一次简短的技术复盘。分享在迁移过程中遇到的最意外的坑、最好的调试技巧、最有效的验证方法。这些经验被沉淀到团队的Wiki中,成为后续迁移的宝贵财富。

5. 工具链与基础设施支持

工欲善其事,必先利其器。一套好的工具链能让迁移事半功倍。

  1. 功能开关服务 :我们引入了成熟的Feature Flag服务(如LaunchDarkly或开源的FlagSmith),它提供了精细的流量切分(按用户、按比例、按地域)、实时变更和降级能力。这比自己在代码里写if-else要可靠和方便得多。
  2. 对比验证平台 :我们开发了一个内部小工具,可以方便地查询影子测试阶段记录的数据。它可以用图表展示新旧结果差异的分布,并支持人工侧栏对比,快速定位新模型处理不佳的案例,用于迭代优化。
  3. 混沌工程实践 :在新服务承载一定流量后,我们会定期进行小范围的混沌实验,例如模拟新Agent依赖的模型服务网络延迟增加、或共享存储短暂不可用。观察系统的降级和恢复能力,确保故障发生时,功能开关能快速将流量切回旧系统(如果旧系统还在),或者新系统有合适的兜底策略。
  4. 全链路追踪 :我们集成了OpenTelemetry等分布式追踪体系。对于一个研究请求,我们可以清晰地看到它途径了哪些尚未迁移的旧服务、哪些已经迁移的新Agent,每个环节的耗时和状态一目了然。这在排查复杂问题时有奇效。

6. 总结:耐心比激情更重要

回顾整个Semi-Autoresearch系统的迁移过程,最大的体会是: 对复杂系统的重构,耐心和严谨远比技术激情重要 。“小步迁移”的本质,是将一个巨大的、不确定性的风险,分解为一系列小的、可控的、可验证的变更。每一步我们都有明确的回滚方案,每一步的结果都有数据支撑。

这种方法牺牲了短期内“焕然一新”的成就感,换来的是业务方几乎无感知的平稳升级,是线上故障的急剧减少,是团队对系统信心的稳步建立。当最后一个旧模块被下线,新的Agent架构完全接管时,那种感觉不是轰轰烈烈的胜利,而是一种水到渠成的踏实。系统在持续提供服务的过程中,完成了新陈代谢。

所以,如果你的系统也面临着“重构”的压力,不妨先放下推倒重来的冲动。试试“小步迁移”这把手术刀,从建立防腐层和契约测试开始,用并行运行和影子测试来验证,通过功能开关来可控地切换。这过程或许不够酷,但绝对足够稳。在商业系统中,稳,就是快。

更多推荐