你接手过一个AI代码生成项目的话,大概率会遇到一个场景:第一版只做“光标处补全”,代码结构很清楚;第二个月加了“选中代码生成函数”“自然语言对话生成”“单元测试生成”之后,代码开始变得不可收拾。各种if-else塞满入口,模型调用散得到处都是,提示词动不动互相污染,改一个模式的行为,其他模式跟着出问题。
我经历过这段混乱,后来花了比较大力气才把架构理顺。这篇文章就是把当时“多种AI代码生成模式”如何做架构设计和逻辑隔离的完整思路记录下来,包括我一开始的错误做法、后面采用的模式抽象、注册与路由、上下文隔离、输出归一化这些关键设计,以及中间踩过的坑。适合正在做AI代码生成插件、内部代码工具或者想给已有项目增加多模式生成能力的朋友参考。
1. 先说清楚:多模式多模式,到底隔离什么
很多人以为“模式隔离”就是把提示词模板拆开,每个模式一份。这在模式数量少的时候确实够用,但一旦超过三个模式就会出问题。因为两个模式之间的差异远不止提示词,输入来源、触发条件、上下文范围、生成策略、流式输出方式、后处理规则、甚至“什么算一次成功生成”,全都不同。
1.1 表面差异与深层差异
拿我们项目里的两个模式举例:
补全模式的特点是“低时延、短输出、强上下文依赖”。它输入的是光标左侧的代码和右侧的代码,期望输出是几行到几十行的补全建议,必须严格遵守现有的缩进和命名风格。对话模式则完全相反,它可以接收自然语言指令、参考整个打开的代码库、进行多轮上下文维护,输出可能覆盖文件的大部分内容,且允许改变原有代码风格。
另一个测试生成模式又不一样,它需要先分析被测函数的出入参、依赖注入方式和Mock策略,然后才能产出单测代码。它的“提示词模板”实际上是由静态分析的结果动态拼装的,跟补全模式的“把前后代码粘进模板”完全是两码事。
我一开始的失败设计,是把这些差异全部塞进一个“万能生成器”,通过判断请求类型选择不同提示词片段。结果就是:补全模式的代码风格约束在生成测试时也被强制执行,对话模式的流式输出逻辑在补全场景下导致首token延迟过高。问题不在于某个模板写得不好,而是这套架构没有为“差异”留出合法位置。
1.2 模式隔离的五个维度
经过几轮调整后,我把模式隔离拆成了五个具体维度:
| 隔离维度 | 核心问题 | 错误做法 | 正确做法 |
|---|---|---|---|
| 触发隔离 | 模式由什么条件激活? | 统一入口里写一堆if-else | 独立路由层,模式不感知别的模式存在 |
| 上下文隔离 | 模式读取哪些代码上下文?怎么维护状态? | 全局共享一个EditorState和ThreadCache | 请求上下文/会话上下文/模式私有上下文分离 |
| 策略隔离 | 模式如何决策生成策略?模型参数如何选择? | 所有模式共用一套temperature和max_tokens | 模式自带策略配置,按需覆盖默认值 |
| 后处理隔离 | 生成结果如何处理? | 一个统一的后处理函数处理所有模式的输出 | 后处理管道可组合,模式声明自己需要的处理链 |
| 状态隔离 | 模式的运行状态存哪里? | 挂在一个全局单例对象上 | 状态绑定模式实例,存活周期与生成任务一致 |
这五个维度不是拍脑袋定的,每一个在当时都出过真实问题。比如触发隔离没做好,用户在高亮代码后输入中文说明,系统有时候走补全模式,有时候走对话模式,同一个操作出现完全不同的行为。这已经不是体验问题,而是产品无法解释的bug。
1.3 不隔离的真实代价
不做隔离的代价是隐性的,不会马上爆发,但会在三个时间点集中咬你:接新模式时、调某个模式的效果时、排查线上问题时。
接新模式时,你会发现所有公共逻辑都长得像“所有模式的交集”。明明只想加一种“解释当前函数”的模式,结果要修改通用生成器、通用后处理、通用路由判断,改一处崩三处。调模式效果时,你改了补全的temperature和提示词格式,第二天测试生成模式的输出质量也变了,因为两个模式共用同一个配置对象。排查问题时,用户反馈某条生成结果特别差,你根本没法从日志确定是走了哪套逻辑、用了哪些上下文、哪条提示词模板。
这些问题叠加在一起,基本就宣告了“模板级隔离”方案的死刑。必须从架构层面给每个模式一个身份,让它们各自拥有完整的生成链路,同时还能共享基础设施。
2. 破局的抽象:把生成过程切成六个稳定阶段
要设计多模式隔离,第一步不是急着拆分模式,而是先找到所有模式共有的结构。我最终把AI代码生成抽象成六个阶段,任何模式都可以表达为这六个阶段的不同实现组合。
这六个阶段分别是:输入识别、上下文构建、策略决策、提示词构造、模型调用与流式接收、输出后处理与落地。
2.1 六个阶段的职责拆解
输入识别的职责是“把编辑器状态和用户行为翻译成模式能够理解的结构化输入”,比如当前光标位置、选中范围、活动文件语言、最近的用户指令。上下文构建的职责是“根据模式需求收集并截断代码上下文”,补全模式只需要当前函数体和相邻区域的token,对话模式则需要相关文件片段和会话历史。策略决策负责确定生成策略,比如是否需要多步推理、是否要调用检索、温度怎么设置。提示词构造把上下文和策略变成具体模型输入。模型调用阶段统一处理请求并发、重试、流式解析。输出后处理负责把模型输出转成可落地的代码建议,可能包括语法修复、格式化、过滤非法内容。
有意思的是,这个阶段划分是在总结“模式之间的不同”时反推出来的。每个阶段都对应一个“模式之间差异最大的地方”。比如上下文构建是差异最大的,因为补全和对话对上下文的定义完全不同;模型调用反而是差异最小的,不管哪个模式最后都要把文本发给模型,然后接收token流。
2.2 模式协议:一段去掉业务细节的代码
有了阶段划分,下一步就是定义一个统一的模式协议,我用类似下面这段代码来表达核心结构:
export interface GenerationMode { readonly id: string; readonly type: ModeType; readonly capabilities: ModeCapability[]; isTriggered(input: EditorInput): TriggerDecision; buildContext(input: EditorInput, state: ContextState): Promise<BuildResult>; planGeneration(input: EditorInput, context: BuildResult): Promise<GenerationPlan>; buildPrompt(input: EditorInput, context: BuildResult, plan: GenerationPlan): PromptPackage; callModel(prompt: PromptPackage, plan: GenerationPlan): AsyncIterable<ModelStreamEvent>; postProcess(result: RawGenerationResult, context: BuildResult): Promise<FinalSuggestion[]>; onError(error: GenerationError, phase: Phase): ErrorRecoveryAction; }这套接口最关键的地方不是方法多,而是每个方法都有明确输入输出边界。模式实现者不需要关心路由层怎么做并发控制,也不需要关心其他模式使用了什么内部状态,只需要把一个阶段做好,把结果交给下一阶段。
比如isTriggered返回的不是boolean,而是TriggerDecision。TriggerDecision里包含“是否触发”“置信度”“触发原因编码”。这样做的目的是让路由决策可以被记录和审查,而不是在黑盒里“猜”一下然后默默走错模式。
2.3 为什么核心调度器不参与模式内部逻辑
设计这个协议的初期,我犯过一个错误:试图把“通用调度逻辑”做得足够聪明,能自动推断每个模式需要什么上下文、后处理该怎么做。结果核心模块变得越来越重,每个新模式都要为核心模块打补丁。
后来我转变了思路,核心调度器只做两件事:根据路由结果选择模式实例,按阶段调用模式的接口方法,然后把各阶段结果传给下一阶段。至于这个模式内部是用一个提示词模板还是十个模板,是直接生成还是做两轮自反思,是同步调用还是流式分块,核心一概不管。这个转变让新模式的接入成本大幅下降,也让我意识到:所谓隔离,本质是把“公共编排”和“模式专属逻辑”的边界划清楚。
核心编排应该提供的功能只有几个:生命周期管理(模式实例何时创建和释放)、运行超时控制、并发限制、日志埋点和链路追踪、以及模式之间共享的只读基础设施(比如模型客户端工厂、静态分析服务、token计数服务)。
3. 模式注册与运行时路由:怎么让新模式像插U盘一样接入
抽象出统一协议后,下一步就是解决“模式怎么被系统发现和使用”的问题。我采用的是注册表加工厂的方式,配合一个独立的路由层。
3.1 注册表与工厂:延迟实例化和模式生命周期
每个模式在启动时向注册表登记自己的元信息,包括模式ID、适用语言、触发类型、优先级和资源配额。注册表不直接保存模式实例,而是保存一个工厂函数。这样做的好处是模式实例可以在第一次被路由命中的时候才创建,避免所有模式一启动就抢占内存权重。
export interface ModeRegistry { register(meta: ModeMeta, factory: ModeFactory): void; get(modeId: string): GenerationMode | undefined; list(): ModeMeta[]; resolveEligible(input: EditorInput): ModeCandidate[]; } export function createDefaultRegistry(): ModeRegistry { const factories = new Map<string, ModeFactory>(); const metas = new Map<string, ModeMeta>(); return { register(meta, factory) { factories.set(meta.id, factory); metas.set(meta.id, meta); }, get(modeId) { const factory = factories.get(modeId); return factory ? factory.create() : undefined; }, resolveEligible(input) { return [...metas.values() .filter((meta) => meta.isLanguageSupported(input.languageId)) .map((meta) => ({ meta, score: 0 }))]; }, }; }注册表还有一个作用:支持配置化启停。我们在配置中心里可以按组织、按语言、按用户灰度开启某个新模式,而模式的代码本身不需要改动。这样“上线一个模式”从部署新代码变成了“注册+配置开启”两步,模式本身只是附加依赖,存在代码库但默认不激活。
3.2 路由层:按输入场景匹配,而不是按“用户选了哪个按钮”
路由层负责回答一个问题:在当前输入场景下,应该激活哪个模式。这里的输入场景是编辑器状态加用户行为的组合,而不是产品里有没有一个按钮。
我用三种信号类型来做路由:意图信号、结构性信号、历史性信号。意图信号来自用户最近的输入方式,比如在行尾回车等待、输入了斜杠命令、在对话框里发送了消息。结构性信号来自当前代码的形态,比如是否高亮了一段代码、光标是否在函数内部、当前文件是否有测试文件。历史性信号来自用户最近的动作序列,比如是否刚刚切换过文件、是否连续触发过多次补全拒绝。
路由的伪代码如下:
export function resolveMode(registry: ModeRegistry, input: EditorInput): ModeDecision { const candidates = registry.resolveEligible(input); const scored = candidates.map((candidate) => { const mode = candidate.factory(); const decision = mode.isTriggered(input); return { mode, decision }; }).filter((item) => item.decision.shouldActivate); if (scored.length === 0) { return { modeId: null, reason: 'no_mode_triggered' }; } scored.sort((a, b) => b.decision.confidence - a.decision.confidence); const winner = scored[0]; if (winner.decision.confidence < ROUTING_THRESHOLD) { return { modeId: null, reason: 'low_confidence' }; } return { modeId: winner.mode.id, reason: winner.decision.reasonCode, alternatives: scored.map(s => s.mode.id) }; }路由层有一个原则:宁可返回“无模式”,也不要强行触发一个低置信度模式。因为AI代码生成是高度依赖用户感知的系统,错误触感的代价比“没反应”更大。用户看到错误模式被激活,会觉得工具“聪明过头了”,而“没反应”只会觉得当前场景不支持。
3.3 路由决策的可审计性
早期版本的路由是没有日志的,线上出现问题很难查。后来我在ModeDecision里挂了reasonCode和alternatives,并要求每个模式在isTriggered中返回具体的决策原因,比如光标不在代码内、语言不受支持、最近一次拒绝信号过强。
这样每条用户请求都留有一份路由日志,可以帮助运营排查“为什么这个请求走了对话模式而不是补全模式”。这个机制在调试路由阈值时特别好用,能直接看到有多少请求被路由到某个模式是因为什么信号类型触发的,而不是靠猜。
4. 上下文隔离:最容易翻车的地方
如果说模式抽象是架构的骨架,那上下文隔离就是血和肉。这一部分做得不好,轻则生成质量下降,重则出现跨会话泄露。
4.1 三种上下文,各管各的
我把上下文分成三类,强制规定它们的使用边界。第一类是请求上下文,生命周期就是单次生成请求,存当前编辑器快照、光标位置、选择的draft内容、时间戳。第二类是会话上下文,生命周期绑定一次连续交互,比如用户连续对话五轮,这五轮的对话历史和系统内部状态都放在这里。第三类是模式私有上下文,只能由模式实现方定义和读写,一般是静态分析结果、自定义的prompt缓存、临时生成的中间表示。
三类上下文的访问规则是:请求上下文对所有模块只读;会话上下文只能由会话管理模块读写,模式实现方不能直接写session状态,只能返回需要追加的数据;模式私有上下文严格限定在模式内部,其他模式无法访问,核心调度器也无法读取。
4.2 防止“串味”的具体手段
光有规则约束不够,还需要技术手段阻止越界。我在实现层做了几个强制措施:
模式实例在创建时被注入一个只含自身key的ContextHandle,这个handle绑定了一个存储命名空间。模式的私有上下文写入时自动带上模式ID前缀,这样即使底层存储是同一个对象,也不会产生key冲突。上下文生命周期由调度器统一管理,请求结束后,请求上下文的引用被清空,句柄进入不可用状态,任何异步回调试图再读取都会抛异常。会话上下文追加数据时必须经过一个schema校验器,将数据转换为可序列化的普通对象,防止某个模式把不可序列化的模型实例或复杂对象塞进会话里。
另外还有一个比较笨但有效的办法:显式拷贝。需要跨模式传递的数据不允许直接传引用,必须序列化后拷贝。这会有少量性能开销,但换来的是绝对隔离。我们有一次排查两天都没找到的bug,最后发现就是因为某个模式把内部的Buffer引用传给了其他模式,另一个模式对这个Buffer做了原地修改,导致第一个模式的缓存数据全变了。从那之后,“引用不跨模式”成了铁律。
4.3 一次上下文串味事故的完整复盘
说一个真实踩坑记录,这个bug让我们整整花了一周时间排查。
现象是用户在使用对话模式时,生成结果会突然“丢失”当前文件的内容,甚至出现过生成内容引用了一个早已关闭文件里的变量。用户反馈说“好像模型能看到我不该看到的代码”。乍一看像是模型幻觉或者代码库索引问题,但后来发现是上下文串味。
定位过程是这样的:先看路由日志,确认走的确实是对话模式;再看上下文构建记录,发现请求里塞入了一个源文件片段,这个文件是用户在十几分钟前打开过又关闭的;继续追查,发现这个片段来自一个名为“编辑器全局快照”的模块,该模块会周期性把最近的文件内容保存到一个全局Map里。对话模式在构建上下文时,误以为这个全局Map就是“当前代码库上下文”,于是把无关文件也读进来了。
根因就是上下文来源不规范。正确做法是对话模式只通过统一的WorkspaceRetriever获取代码库内容,并且在该Retriever内部做好文件过滤和权限校验。修复方案也比较直接:对话模式不再直接读取全局快照,改成传递一个经过会话上下文确立的“工作区范围参数”,所有文件读取都受这个参数约束。另外给请求上下文增加一个“允许文件列表”的字段,任何上下文构建逻辑返回值如果包含列表之外的文件,就直接截断并记录告警。
这个事故让我意识到,上下文隔离不能只靠一套抽象,必须叠加“源头限制”和“结果校验”两道保险。
5. 输出归一化与后处理管道:模式各异,产物对齐
模式可以千差万别,但最终落到编辑器的产物必须是一个统一的结构。否则IDE插件端、用户反馈端、汇总统计端全都需要感知模式差异,架构又会变得混乱。
5.1 统一结果对象:一块干净的中间层
我设计了一个建议块作为所有模式产出的统一格式,它的核心字段包括建议ID、模式ID、变更类型、代码块列表、说明文本、置信度评分和元信息。代码块列表里每一项都包含路径、起始与结束位置、原始代码、新代码、是否需要用户确认等字段。说明文本是可选的,用于对话模式这种“不只是改代码”的场景。
统一的结果对象带来的一个直接好处是插件端渲染逻辑只需要处理一种结构,无论是补全建议、测试生成结果还是对话回复,渲染层拿到的都是同一份suggestion列表。后端的存储和分析模块也不用为每种模式写单独的持久化逻辑。
5.2 后处理管道按模式组装
后处理是最容易被当成“公共工具函数”的部分。我最初把所有后处理逻辑放在一个CommonPostProcessor里,比如格式化、括号补全、注释清理全部放在里面,任何模式的结果都会走一遍。
后来问题来了:测试生成模式产出的内容需要做build验证,对话模式产出的大段重构代码不需要;补全模式产出的代码经常只有半句,强制做格式化会把半句格式化成语法错误的形式。于是我把后处理改成了可组合的过滤器管道:
export interface PostProcessPipeline { run(result: RawGenerationResult, context: BuildResult): Promise<FinalSuggestion[]>; } export function buildDefaultPipeline(mode: GenerationMode): PostProcessPipeline { const filters: PostProcessFilter[] = []; if (mode.capabilities.includes('syntax_preserving')) { filters.push(new SyntaxPatchFilter()); } if (mode.capabilities.includes('formatter_required')) { filters.push(new FormatterFilter()); } if (mode.capabilities.includes('build_virtual_check')) { filters.push(new VirtualBuildFilter()); } if (mode.capabilities.includes('duplicate_guard')) { filters.push(new DuplicateCodeFilter()); } return new Pipeline(filters); }每个模式在声明capabilities时就说清楚自己需要哪些后处理环节。核心调度器在模式完成模型调用后,调用buildDefaultPipeline获取该模式的后处理链,然后逐个执行。这样“补全模式上边框补全”和“对话模式上代码解析”这两件事,在代码层面就是完全正交的。
5.3 质量门禁与自动降级
输出统一之后,还需要一个质量门禁。质量门禁的目标不是让所有模式“输出完美代码”,而是在明显失败时不要打扰用户。我设置了三道门禁:语法校验、重复内容检测、置信度阈值。
语法校验通过快速解析器判断生成代码是否存在明显语法错误,无法解析的输出会被降级为“仅文本说明”。重复内容检测用来识别模型重复输出同一段代码的问题,这在模型比较差或者上下文过长时经常出现。置信度阈值则由模式自己提供,比如对话模式在上下文不足时会主动给出低置信度,此时不落地为代码修改,而是显示为普通回答。
如果后处理阶段发生异常,调度器会根据onError返回的恢复动作执行降级:如果某个模式的后处理管道失败,则把该轮生成标记为失败,不展示给用户,同时在上报数据里写好失败原因,方便后续迭代优化。
6. 新增一个模式的完整落地流程
理论聊完,聊点实践。我们按这套架构新增一个“为当前方法生成单元测试”的模式时,整个流程大约可以拆成七个步骤,每一步都有明确验收标准。
6.1 从接口到上线的七个步骤
第一步是定义触发规则。我们选择“用户打开非测试文件且光标在方法内,并且通过斜杠命令触发”作为触发信号,实现isTriggered方法。验收标准是触发决策在日志里可复现。第二步是定义上下文需求,实现buildContext:静态分析出当前方法、所在类、依赖的Service、已存在的测试风格。验收标准是上下文构建结果能在日志中完整展示。第三步是定义生成策略,实现planGeneration:如果方法复杂度低,用单提示词直接生成;复杂度高,则先生成一个“测试计划”,再基于计划生成测试代码。
第四步是配置模型参数,包括温度、最大token数、topP。测试生成场景温度建议偏低,设为0.2到0.3,补全模式甚至可以更低。最大token数要覆盖最长可能的测试方法,但不能无限大。第五步是声明后端处理能力,也就是capabilities。我们要声明formatter_required、build_virtual_check、duplicate_guard。第六步是发布注册,把模式工厂函数注册到注册表,先在内部测试环境打开配置。第七步是灰度上线,按用户百分比开流量,实时盯接受率和错误率。
整个过程中,最耗时的是buildContext和planGeneration,因为它们决定了生成质量的天花板。提示词再怎么调,如果静态分析没有拿到被测函数的边界条件,生成出来的测试代码就是纯凑数。
6.2 新模式上线要盯的核心指标
不同模式的指标侧重点不同,但我认为以下几个指标是所有模式通用的:
| 指标 | 定义 | 意义 |
|---|---|---|
| 触发准确率 | 实际路径与路由日志一致且用户未手动纠正的占比 | 反映路由层是否可靠 |
| 建议接受率 | 用户接受或手动应用生成结果的次数除以生成总次数 | 反映生成质量 |
| 端到端时延 | 从触发到结果展示的耗时 | 影响用户感知流畅度 |
| 上下文有效使用率 | 被模型实际利用的上下文token数除以总发送token数 | 反映上下文构建是否浪费 |
| 故障恢复率 | 模式发生错误后自动降级成功且用户可感知的占比 | 反映容错设计是否有效 |
| 单体成本 | 每千次生成的token消耗和API费用 | 直接决定功能能否可持续 |
这些指标最好不要分散在各个数据表里,而是统一以模式ID为主键汇总,这样新模式上线后的监控仪表盘可以直接套用。
6.3 性能与成本控制的设计取舍
多模式并存时,性能和成本是一个避不开的话题。我的第一个模式实例是懒加载的,但懒加载只在首次调用时分担占用,一旦多个模式都在活跃使用,它们的上下文构建和静态分析可能并行抢占CPU。
我做了两层控制。第一层是并发控制:每个模式允许的最大并发请求数可配置,补全模式因为时延敏感,配置较高的并发上限;测试生成模式耗时本就长,并发上限调低一些,防止拖垮主进程。第二层是资源共享:模型调用有统一连接池,token计数使用共享词表缓存,静态分析结果按文件路径和修改时间做了缓存。
成本控制方面,我们给每个模式设置了独立的token预算和tier。对于测试生成模式,允许tier1的高质量模型;对于补全模式,默认使用速度更快的低配模型。用户可以不感知这些细节,但架构上必须让每个模式能独立指定模型分组,否则一个模式升级模型会连带其他模式一起烧钱。
7. 回看经验:哪些设计值得坚持,哪些可以简化
这套架构演进到现在,已经稳定支撑了四种模式。回头总结,有些设计我认为是必须坚持的,有些则可以视项目阶段简化。
必须坚持的是:模式协议的统一、路由层的独立、上下文三类分离、结果对象统一。这四件事决定了系统能不能继续生长。不论项目多小,只要决定支持多种生成模式,这四个地基不能省。
可以简化的是:工厂和注册表,在模式数量小于等于三个时,一个switch-case加策略类也够用。后处理管道不一定要做成通用pipeline框架,先在每个模式内部写两个私有函数,等组合逻辑变复杂了再抽管道。还有灰度发布和数据看板,内部工具可以先不做,用一个配置文件开关加手动查看日志也能顶一段时间。
我不建议一开始就搭一个完整插件系统,这属于过度设计。但建议从第一天就坚持一个原则:任何一个模式都不允许读取另一个模式的内部状态,任何一个模式都不允许知道另一个模式是否存在。实现这个原则不需要复杂框架,只要注册表和路由层把模式实例互相隔离即可。
7.1 三次让我后悔的决策
第一次后悔是做了一个“万能生成器”,把补全和对话合成一个模式,用参数区分。后果是每次改补全的行为都会影响对话,最后代码被拆成两套核心逻辑,等于白写了一遍。第二次后悔是没有在上下文构建阶段做文件白名单校验,导致了一个跨文件泄露事故,排查了两周。第三次后悔是上线新模式时跳过灰度期直接全量开,结果某模式在特定语言下的生成质量非常差,用户反馈爆炸。
7.2 我最后的一套收藏级经验
给正在做类似事情的朋友几个高性价比的建议。
每个模式在日志里必须有“身份指纹”,也就是模式ID加版本号加触发原因。排查问题时如果日志里看不到这些信息,基本靠猜。模式隔离不是“各写各的类”,而是“状态和数据的访问边界”。类可以随便写,数据一旦跨边界流动,隔离就失效了。每个模式上线前,至少要拉长日志检查一遍上下文构建结果的完整记录,看看有没有不该出现的外部数据混进来。
如果让我重新选一个切入点,我一定会先搭好统一模式协议,再在协议基础上做第一个模式,而不是先完成第一个模式再回头提取协议。因为后者会让你把第一版的实现细节错误地“洗白”成某个抽象阶段,等第二个模式进来时发现协议设计错了,推倒重来的成本远高于一开始慢半拍。