做代码生成类基础设施那阵子,我印象最深的一件事,就是“多模式”这三个字听起来简单,真正落地的时候能把架构逼到什么程度。一开始项目只有补全模式,跑得挺好,等对话、单测生成、重构建议这些模式陆续排上日程,原来的单条生成逻辑马上就不够用了:每个模式要的上下文不一样,提示词约束不一样,输出格式不一样,连失败后的降级策略都不一样。硬塞进同一个管道的结果就是互相污染,补全出来的代码带着对话口吻,对话模式又把补全命令当用户问题回答。这篇文章我想从架构层面拆一拆,多个AI代码生成模式到底该怎么设计,怎么把各自的生成逻辑隔离开,既让它们互不干扰,又能共享底层能力。
文章的切入点更适合正在做AI编程助手、代码生成服务、内部开发工具的人,也适合想搞清楚“策略隔离”到底怎么落地的新手架构师。我不讲具体某个模型怎么调参,而是讲一套可复用的分层思路和踩坑经验。
1. 需求先行:多模式到底在解决什么问题
1.1 “模式”这个词背后是几类完全不同的任务
所谓“多种AI代码生成模式”,拆开看其实是一组语义差异非常大的任务。至少包括:
- 自动补全:基于光标前的代码,预测后续内容,要求低延迟、流式输出、不打断思路。
- 对话问答:理解开发者自然语言提问,结合当前文件、报错信息,进行多轮交互。
- 测试生成:扫描现有代码和测试框架,生成符合项目惯例的单测代码。
- 重构建议:分析代码结构,指出可重构点,给出修改方案或直接生成新版代码。
- 代码解释:对选中代码块进行说明,输出偏向文档,而不是可执行代码。
这些任务共享同一个底层模型调用能力,但上下文组织方式完全不同。补全只需要关注光标附近几十行;对话需要把历史消息、当前文件、错误栈一起组装;测试生成则必须知道项目里用的是哪套测试框架、已有测试的写法风格。如果这些逻辑都写在一个函数里,代码很快就会变成一堆if-else的分支地狱。
1.2 不做隔离会失控到什么程度
我在早期版本里踩过直接后果。当时只有一个“生成”接口,里面用参数区分是补全还是对话。结果出现了三类问题:
第一是提示词串扰。补全模式的系统提示词里写“只输出代码”,对话模式复用同一套模板后,模型在回答用户问题时也拒绝输出解释文字,用户体验极其奇怪。表面上是模板没拆干净,实际上是因为所有模式共用了一个prompt构造函数,谁也没法独立演进。
第二是上下文不匹配。补全模式为了低延迟,只携带最近64行代码。切换成对话模式后,开发忘了清理这段逻辑,导致用户问“这个模块为什么卡顿”时,模型看到的上下文只有光标附近的几十行,完全无法回答全局性问题。
第三是故障互相拖累。补全模式是延迟敏感型,设置的是300ms超时、少量重试。对话模式因为是长文本生成,需要更长的等待时间。两者共用一个模型调用封装,结果补全请求频繁因为“超时”走到重试分支,把队列塞满,对话请求也跟着变慢。
一个模式的问题扩散到所有模式,这就是不做隔离的代价。
1.3 架构目标要先于代码写下来
动手设计前,我给这套多模式架构定了四个目标,建议做同类项目的人也可以参考:
| 目标 | 落地含义 |
|---|---|
| 可插拔 | 新增模式时不需要改动已有模式代码,只需新增一个策略类并注册 |
| 互不拖累 | 每个模式有独立的超时、并发、降级策略,一个模式故障不影响其他模式 |
| 体验统一 | 用户看到的交互方式、错误提示、流式事件格式保持一致 |
| 可观测 | 任何一次生成都能追溯到是哪个模式、哪个阶段、哪套提示词版本 |
这四个目标后来成了我判断架构设计是否合格的标尺。很多方案表面精致,一比对目标就发现根本做不到“新增模式不动旧代码”。
2. 总体拓扑:入口统一、处理分叉、出口归一
2.1 统一入口和模式判定是第一道闸门
无论前端是IDE插件、网页聊天框还是命令行工具,请求进来后先进入一个统一的入口路由。这一层不关心模式内部怎么做上下文组装,只负责三件事:识别模式、校验参数、分配请求ID。
模式识别有几个来源,优先级从高到低我一般这样定:
- 用户显式选择。比如用户点了一个“生成测试”按钮,那就不需要猜测。
- 触发场景。比如光标在代码文件中、按下Tab,那默认是补全模式。
- 启发式意图识别。比如用户输入“请解释这段代码”,路由层根据简单规则或轻量分类模型判断为解释模式。
这里有个容易忽略的点:入口层做完判定后,一定要生成一个全局唯一的请求ID,并且把模式信息、提示词版本、上下文快照版本都挂在日志上。后面排查问题,全靠这个ID串起全链路。
2.2 编排层只做路由,不碰具体逻辑
入口下面是编排层。很多人会把业务逻辑直接写在这一层,比如“如果模式是补全,就调用补全服务;如果是对话,就调用对话服务”。这其实是把路由和业务耦合了,后续每加一种模式都要改这段代码。
我更推荐的做法是让编排层只持有一个模式注册表,根据入口层识别出的模式名,从注册表里取到对应的执行器,然后统一执行。伪代码如下:
class GenerationOrchestrator: def __init__(self, registry: ModeRegistry): self.registry = registry def run(self, request: GenerationRequest) -> GenerationResult: mode_name = request.mode executor = self.registry.get(mode_name) context = executor.build_context(request) prompt = executor.build_prompt(request, context) raw_output = executor.call_model(request, prompt) result = executor.parse_response(request, raw_output) return self.post_process(mode_name, result)注意编排层调用的都是executor接口上定义的方法,它不关心executor内部是走代码补全还是多轮对话。这样新加模式时,编排层一行不用改。
2.3 为什么采用“共享内核 + 独立执行器”而不是每个模式拆成独立服务
架构评审时,有人提过把补全、对话、测试生成各自拆成微服务,独立部署。我没有采纳,原因是这些模式的核心依赖高度重叠:都要走模型调用、都要做代码上下文检索、都要解析模型输出、都要生成diff。如果拆成独立服务,重复代码会大量出现,而且任何底层模型升级都得同时发布好几个服务。
最终用的是“共享内核 + 独立执行器”的方案:底层公共能力放在共享库中,模式的业务逻辑封装成独立执行器,进程内隔离。这样既避免了重复建设,又能在代码层面把不同模式的上下文、提示词、参数隔离得干干净净。进程级隔离不是不需要,而是只有当某个模式流量特别大、需要独立扩缩容时才考虑拆出去,属于后续演进路径,不必一开始就过度设计。
3. 模式隔离的四个关键维度
3.1 代码结构上先定义好策略接口
隔离的第一步,是给每个模式一个稳定的边界。我这里用的是一套策略接口,名字可以叫CodeGenMode或GenerationStrategy,核心方法就四个:
class CodeGenMode: name: str version: str def build_context(self, request) -> GenerationContext: """根据请求构造上下文,包括代码片段、符号表、历史消息等""" pass def build_prompt(self, request, context) -> PromptSpec: """将上下文和指令组装成模型输入,返回带版本号的提示词规格""" pass def call_model(self, request, prompt) -> ModelOutput: """发起模型调用,可包含独立的超时和重试策略""" pass def parse_response(self, request, output) -> GenerationResult: """把模型原始输出解析为结构化结果,比如diff、多行代码或对话文本""" pass这个接口的粒度非常关键。我没有把“获取代码上下文”“切分历史消息”纳入接口,而是拆给了内部辅助类,因为不同模式的上下文来源差异太大,硬统一反而会限制各自的发挥。接口只定义四个稳定的业务阶段:造上下文、造提示词、调模型、解析输出。
注册表用简单的字典实现:
class ModeRegistry: def __init__(self): self._modes = {} def register(self, mode: CodeGenMode): self._modes[mode.name] = mode def get(self, name: str) -> CodeGenMode: if name not in self._modes: raise ModeNotFoundException(name) return self._modes[name]注册表的价值不仅是查找,它还强制要求每个模式名唯一。曾经有人把一个模式的version放在类变量里,导致并发请求下版本号串了,排查了很久。后来我规定version必须在注册时显式传入,不允许从类属性读取。
3.2 上下文组装隔离:让每个模式拥有独立的上下文流水线
上下文组装是整个多模式架构里最容易被污染的部分。常见的错误是搞一个UniversalContextBuilder,里面根据模式名分支处理。这样看似省事,实际上每个新增模式都会改这个类,最后变成一个没人敢动的上帝类。
正确的做法是让每个模式自己实现build_context方法,内部可以自由组合公共检索能力,但输出的GenerationContext是独立的。我做过一个标准化方案,Context里至少包含以下字段:
- source_snippets:代码片段列表,每个片段带文件路径和起止行号。
- cursor_position:光标位置,补全模式必填。
- history_messages:历史对话消息,对话模式必填。
- project_hints:项目配置、测试框架、依赖信息等。
- token_budget:当前模式允许的上下文token上限。
不同模式的token预算差别很大。我通常在配置中心里维护一张表:
| 模式 | 默认上下文Token预算 | 说明 |
|---|---|---|
| 代码补全 | 4096 | 低延迟,只关注光标附近代码 |
| 对话问答 | 16000 | 需要容纳历史消息和当前文件 |
| 测试生成 | 12000 | 需要读取被测代码和测试样例 |
| 重构建议 | 8000 | 需要分析结构,不需要太多历史 |
| 代码解释 | 8000 | 以选中代码和当前文件为主 |
预算在build_context阶段就强制执行,超出部分或者截断、或者走代码检索只保留关键片段。这一步做得好,后面提示词组装才会稳定。
3.3 提示词模板隔离:版本化和独立演进
隔离模式的生成逻辑,最核心的是隔离提示词模板。我见过很多项目把所有模式共用一个prompt模板,通过字符串拼接塞不同指令。这种做法的隐患在于,模板里任何一处针对某个模式的小修改,都可能影响其他模式的生成语义。
我的做法是每个模式维护一套独立的模板目录,模板文件按模式名和版本号组织:
prompt_templates/ completion/ v1/ system.txt user_prefix.txt user_suffix.txt chat/ v1/ system.txt history.txt user.txt v2/ system.txt history.txt user.txt test_generation/ v1/ system.txt user.txtPromptSpec里记录模板版本号,提交到日志,方便追溯。这样调优某个模式的提示词时,不会牵扯其他模式。更进一步,新模板可以先灰度,因为版本号是显式的,接口层可以按用户组或请求比例决定加载v1还是v2。
这里有一条经验:系统提示词不要放太多“禁止类”指令。曾经有团队为了防止模型在补全模式下输出解释文字,在system里写了四五条“不要解释”“不要闲聊”,结果模型确实不解释了,但也开始拒绝补全完整函数。后来把约束拆成了“系统角色设定”和“输出格式约束”两部分,前者放到system,后者放到user模板末尾,效果好了很多。
3.4 算力与故障隔离:每个模式独立超时、重试和熔断
模型调用是瓶颈资源,多模式共用一个调用入口时,必须做资源隔离。这里说的不是物理隔离,而是逻辑隔离,包括四个参数:并发上限、超时时间、重试次数、熔断阈值。
我整理过一个推荐配置:
| 模式 | 并发上限 | 超时 | 最大重试 | 熔断阈值 |
|---|---|---|---|---|
| 代码补全 | 200 | 500ms | 1 | 5% 错误率/分钟 |
| 对话问答 | 50 | 30s | 2 | 10% 错误率/分钟 |
| 测试生成 | 30 | 60s | 1 | 10% 错误率/分钟 |
| 重构建议 | 30 | 40s | 1 | 10% 错误率/分钟 |
为什么补全的重试次数是1?因为补全场景低延迟优先,一次重试已经是极限。对话为什么容忍30秒?因为用户预期是等待完整回复。这些参数如果全局统一,一定是慢速模式拖垮快速模式,这是我在生产环境里验证过的。
故障隔离的实现用经典的熔断器框架就好。每个模式在注册表里维护一个独立的熔断器实例,熔断后直接降级,不调用模型。降级策略也放在模式内部定义,比如补全模式熔断后可以降级为“基于索引的本地模板补全”,对话模式熔断后则提示“当前AI服务暂不可用”。
4. 共享内核:隔离不等于完全复制
4.1 哪些能力应该被所有模式共享
如果每个模式都把底层能力重新实现一遍,那隔离就变成了重复造轮子,维护成本反而更高。我梳理下来,值得共享的能力有四类:
- 代码解析:AST解析、函数边界识别、变量作用域分析。
- 符号检索:基于索引的类、函数、变量查找,支持按文件名和行号定位。
- 代码检索:语义检索或BM25检索,从仓库中找出与当前问题相关的片段。
- 输出后处理:diff计算、代码格式化、括号闭合校验。
这些能力的共性是:它们不依赖某个模式的具体指令,只是把代码工程中的结构化信息提取出来,供不同模式按需使用。与其让每个模式自己写一套,不如做成共享库。
4.2 公共能力如何不反向耦合模式逻辑
共享不是免费的。一个典型的反向耦合案例:某个团队一开始把代码检索结果直接拼接在通用Context对象里,补全模式需要时读一下,对话模式也需要时读一下。看起来没问题,后来发现检索模块为了迎合“对话模式”的需求,悄悄在结果里加入了“相关历史提交信息”,而补全模式不需要这些,白白浪费了token,还拖慢了组装速度。
解决思路是:公共能力只返回标准化数据,不负责决定数据怎么用。比如语义检索,公共层提供search(query, limit=10)方法,返回的是List[CodeSnippet],至于每个模式是取前三条还是拼接成对话上下文,完全由模式自己的build_context决定。
共享层和模式层之间的调用关系保持单向依赖:模式层依赖共享层,共享层绝不反向感知模式的存在。这是避免共享变成耦合的关键。
4.3 输出后处理管道值得单独设计
普通用户感知不到后处理的存在,但这一步直接决定生成结果是“能跑的代码”还是“看起来像代码的文本”。我把它设计成一组filter,每个模式可以选择不同的filter组合:
POST_PROCESS_FILTERS = { "trim_markdown_code_fence": TrimMarkdownCodeFence(), "extract_first_code_block": ExtractFirstCodeBlock(), "balance_brackets": BalanceBrackets(), "apply_diff": ApplyDiffToEditor(), "compile_quick_check": CompileQuickCheck(), }补全模式的后处理链通常是:去掉markdown围栏 -> 括号闭合校验 -> 返回增量文本。测试生成模式的后处理链更长:去围栏 -> 提取代码块 -> 识别测试类名 -> 校验是否与现有测试重复 -> 提示词版本记录。后处理链条要保持可组合、可排序,并且每个filter都要有独立的开关和日志。
4.4 守门校验放在管线末尾但不要一刀切
模型生成结果不经过校验就直接上屏,问题会非常多。但守门校验的严格度必须分模式。补全模式如果每次生成都做完整编译,延迟会高到不可接受,所以只做轻量校验:括号是否配对、缩进是否明显错乱、是否出现明显截断。测试生成和重构建议模式则可以做较重的校验,比如AST解析是否成功、生成的代码是否引用了不存在的符号。
这又是一个“共享”和“隔离”需要平衡的点。校验引擎本身是共享的,但校验规则由每个模式自己声明。这样既复用了解析能力,又不会因为某个模式校验收紧而拖累其他模式。
5. 落地实战:一次典型的多模式请求全过程追踪
5.1 从IDE事件到模式判定
我以实际项目为例。开发者在编辑器里打开一个函数,选中一段代码,按下了快捷键“生成单测”。IDE插件把这个动作翻译成请求,传给后端入口:
{ "request_id": "req_20250110_001", "mode": "test_generation", "action": "generate_unit_test", "file_path": "src/services/order_service.py", "selected_range": {"start": 12, "end": 48}, "framework_hint": "pytest" }入口层校验mode是否在注册表里,发现是test_generation,就交给编排层。编排层从注册表拿到TestGenerationMode实例,开始执行。
5.2 测试生成模式的完整执行管线
这个模式的执行管线大致如下:
- build_context阶段:读取选中函数源码,解析AST拿到函数名、参数、返回值类型;读取项目根目录的测试配置文件,确认pytest风格;检索项目里已有的测试文件,抽取相似测试写法作为风格参考。整个过程严格限制在12000 token预算内。
- build_prompt阶段:把“被测代码”和“测试风格示例”分别填入模板,system模板只做角色设定,user模板明确要求“生成可执行的pytest测试用例”。
- call_model阶段:按测试生成模式配置的超时和重试策略发起调用。这一步不会因为补全模式流量高峰而改变参数。
- parse_response阶段:用后处理链去掉可能的markdown围栏,提取第一个完整代码块,再通过AST校验确认生成内容是一个包含test_开头的类或函数。
- 校验和返回:兼容性校验发现生成代码调用了
order_service.get_total(),但现有API里没有这个方法。管线没有选择直接报错,而是把提示信息一起返回给用户,让用户决定是否采用。
全程跟踪下来可以看到,模式隔离不是把代码放到不同目录那么简单,而是从上下文构造开始,到模型输出结束,每一步都走独立逻辑,只在底层复用公共解析和检索能力。
5.3 多模式并行时的调度与取消
现代IDE里,用户可能同时开着对话窗口和自动补全,一次性会有多个模式的请求并发。如果所有请求共用一个无界线程池,补全这种高并发低延迟请求会被对话这种长请求抢占资源。
我的方案是为每个模式维护独立的队列和线程池。池大小按照前面表格里的并发上限配置。比如补全池有200个线程,对话池只有50个。线程池之外,还要处理一个隐藏问题:用户可能取消请求。取消动作要能穿透模式边界,直接中断模型调用。实现方式是给每个请求分配一个CancelToken,在编排层调用call_model时传入,取消后后处理管线也同步终止,避免继续浪费资源。
5.4 一次故障切换的现场记录
有段时间补全模式上游模型服务偶发5%错误率,触发了熔断。熔断打开后,补全请求不再进入模型调用,而是走了降级路径。我当时在日志里看到这样一条记录:
mode=completion stage=call_model circuit=open fallback=local_snippet_completion decision=fallback duration_ms=45补全被降级为基于索引的本地片段补全,速度反而更快,只是生成质量下降。关键是对话、测试生成模式没有受任何影响,因为各自的熔断器是独立的。这个现场记录也验证了资源隔离的价值:如果一个模式熔断导致全平台不可用,用户大概率不会再打开第二次。
6. 踩坑实录与排查清单
6.1 六个最典型的设计错误
先把我踩过或者见过别人踩的坑列出来,每一个都对应着一条架构经验。
第一个坑是上下文共享池导致token爆炸。最初我把检索结果放在一个全局缓存里,所有模式共享,结果补全模式频繁因为上下文超限被截断。原因是一个对话请求把大量历史消息写进了缓存,补全模式又把缓存内容拼接进了自己的上下文。解决方法是坚持每个模式build_context独立,公共检索结果只是“可引用”而不是“自动注入”。
第二个坑是提示词模板公共文件被悄悄改坏。一次排查发现对话模式输出格式突然变化,查了半天发现是有人为了修复补全模式,在公共system提示词里加了一句“不要使用markdown”。对话模式因此也不再生成markdown格式的列表。从那以后我强制模板按模式分目录,任何跨模式修改必须走评审流程。
第三个坑是全局统一超时参数。前文提过,不再展开。一句话总结:超时和重试必须按模式配置。
第四个坑是错误信息丢失模式上下文。有的报错只返回“模型调用失败”,没有携带mode和stage字段,根因定位要花很久。后来我要求所有异常对象必须携带request_id、mode、stage三类信息。
第五个坑是新增模式时悄悄改了共享代码。新人接手时发现新增模式需要额外校验,于是在注册表的get方法里加了个if判断,结果所有模式都受了影响。教训是共享层的修改必须经过影响面评估,新增功能优先放在模式实现内部。
第六个坑是并发下模式状态被放在单例里。某些模式的统计信息如果作为类变量保存,高并发下会相互覆盖。模式实现应当是无状态的,或者状态显式挂到请求对象上。
6.2 排查:如何快速定位是模式层问题还是共享层问题
多模式架构的好处是故障隔离,但代价是排查路径变长。我自己的排查顺序是:
- 先看request_id是否贯穿所有日志,没有就打回重做。
- 看错误发生在哪个stage。如果发生在build_context之前,大概率是入口或路由问题。
- 如果同一个模式所有请求都失败,先查该模式的配置,比如token预算、超时、熔断状态。
- 如果所有模式同时失败,才去查共享层:模型服务、认证、基础组件。
按照这个顺序,通常能在几分钟内定位问题。排查清单我整理成了表格:
| 症状 | 可能原因 | 主要检查点 |
|---|---|---|
| 单个模式超时 | 该模式超时配置过短或上游模型变慢 | 检查该模式的重试和熔断指标 |
| 所有模式都变慢 | 底层模型服务容量不足 | 检查公共连接池、模型上游健康状态 |
| 生成内容风格突变 | 提示词模板被跨模式改动 | 对比模板版本号和模板文件变更历史 |
| 上下文频繁被截断 | token预算分配错误或上下文组装混用 | 查看该模式build_context日志和截断点 |
| 新增模式不生效 | 注册表未注册或模式名不匹配 | 校验入口请求中的mode与注册表一致性 |
6.3 平滑扩展一个新模式的标准流程
基于上面的架构,新增模式不再需要推倒重来。我建议按下面五步走:
- 定义模式边界。明确这个模式的输入是什么、输出是什么、上下文来源是什么。
- 实现CodeGenMode接口,先复制最接近的现有模式作为起点。
- 配置独立的资源参数:并发、超时、重试、熔断阈值、token预算。
- 接入灰度开关,先让内部用户试用,对比生成质量和延迟。
- 灰度通过后全量上线,保留老模板版本号以备回滚。
第五步很多人忽略。回滚不是把代码回滚,而是只需要让配置中心把模板版本指回旧版本,新版本停止接流量。模板版本化在这个流程里起了决定性作用。
还有一个扩展技巧:新模式上线初期,不要直接让它成为一个正式模式,可以先注册成一个内部模式,通过请求头或配置开关触发,等验证充分后再开放。这样可以避免“新模式生成结果不可控就暴露给全部用户”的尴尬局面。
我个人在实际项目里最深的体会是:模式隔离的架构,本质上是在为「演进速度」买单。前期多花一点时间确定接口边界和资源隔离方案,后期每增加一个生成模式都能保持稳定。如果你正在做一个多模式的代码生成系统,我建议第一版就把CodeGenMode这个接口立起来,即使只实现一个补全模式,也不要跳过注册表和独立上下文的设计。后面当对话、测试生成、重构建议一个接一个排进迭代时,你会感谢当初这个决定。最后再分享一个小经验:隔离的边界宁可多分一个阶段,也不要为了省事合并两个阶段。阶段拆细了,排查和扩展都容易;合并了,短期内代码少,长期全是账。