一个大型 AI Agent 项目的架构不是凭空产生的------它是对约束的回应。kimi-code 在三年多的迭代中沉淀了一系列关键架构决策,这些决策今天仍然在塑造着每一行代码。本文逐个分析五个影响最深远的架构选择:它们面临什么问题、有哪些替代方案、以及为什么最终选择了这条路。
1. DI 容器模式(借鉴 VS Code)
面临的问题
kimi-code 不是一个小脚本。它有数十个服务(配置管理、LLM 请求、工具执行、权限控制、会话状态......),这些服务之间存在复杂的依赖关系。如果每个模块都手动 import 依赖并 new 实例:
- 替换一个服务实现需要改数十处
import - 测试时需要侵入式 mock 模块系统
- 不同作用域(应用 / 会话 / 智能体)的实例无法隔离
- 生命周期管理分散在各处,容易泄漏
核心矛盾:需要一种既支持服务组合、又支持作用域隔离、同时不引入第三方依赖的模式。
可选方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 直接 import | 零学习成本 | 紧耦合,不可替换,无法隔离 |
| Service Locator(全局单例) | 简单解耦 | 隐式依赖,难追踪,无法作用域化 |
| tsyringe / inversify | 成熟的 DI 框架 | 引入第三方依赖,签名不匹配 VS Code 习惯 |
| VS Code 风格 DI(选定) | 零依赖,团队熟悉,实例隔离 | 需要手动实现容器逻辑 |
最终选择:自研 VS Code 风格 DI
项目选择了借鉴 VS Code 的 DI 容器设计。DI README 中明确写道:「Two reasons: Zero runtime dependencies --- the container is ~600 LoC of plain TypeScript; Familiar shape --- most kimi-code contributors have seen VSCode's service pattern.」
核心设计细节
createDecorator 模式
createDecorator 是 DI 容器的入口。调用它传递一个字符串名称,得到一个兼具三种身份的值:
less
// 1. 服务标识符(ServiceIdentifier):作为 Map 的 key
// 2. 参数装饰器(Parameter Decorator):用于构造函数注入
// 3. 诊断名称:toString() 返回构造函数名
const ILogger = createDecorator<ILogger>('logger');
// 使用:作为参数装饰器声明依赖
class SomeService {
constructor(
@ILogger private readonly logger: ILogger,
@IClock private readonly clock: IClock,
) {}
}
关键实现细节:singleton-per-name ------两个 createDecorator('logger') 返回完全相同的标识符引用。这确保了跨模块引用的一致性。
IInstantiationService:作用域化服务解析
IInstantiationService 是运行时的服务容器契约:
php
interface IInstantiationService {
readonly _serviceBrand: undefined;
invokeFunction<R>(fn: (accessor: ServicesAccessor) => R): R;
createInstance<T>(descriptor: SyncDescriptor0<T>): T;
createInstance<Ctor>(ctor: Ctor, ...args: GetLeadingNonServiceArgs): InstanceType<Ctor>;
createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService;
dispose(): void;
}
核心行为:
- singleton-per-container :同一个容器内多次
get返回同一实例 - createChild:创建子容器,子容器继承父容器的注册,但可以覆盖
- auto-injection:通过 TypeScript 参数装饰器元数据自动解析
- cycle detection:基于 Graph 子树遍历,在构造函数执行前发现循环依赖
延迟实例化 (Proxy)
当服务标记 supportsDelayedInstantiation: true 时,容器返回一个 Proxy,真正的构造函数在第一次非事件属性访问时才执行:
javascript
const proxy = ix.invokeFunction((a) => a.get(IFoo));
// Foo 的构造函数尚未执行
proxy.ping();
// 构造函数在 ping() 访问时首次执行,后续调用命中缓存
延迟实例化还支持早期监听器(early-listener contract):在 Proxy 上订阅 onDidChange 事件会暂存监听器,当真实实例创建后自动重绑定。
DisposableStore:生命周期管理
与 VS Code 的 dispose 模式一致,项目实现了完整的生命周期体系:
typescript
// 基础契约
interface IDisposable { dispose(): void; }
// 持有子 disposables 的基础类
abstract class Disposable implements IDisposable {
protected readonly _store = new DisposableStore();
protected _register<T extends IDisposable>(d: T): T {
return this._store.add(d);
}
dispose(): void { this._store.dispose(); }
}
// 可变 disposables:动态替换
class MutableDisposable<T extends IDisposable> implements IDisposable {
get value(): T | undefined { ... }
set value(value: T | undefined) { ... } // 自动 dispose 旧值
}
容器 dispose 时按 LIFO 顺序(反向构造顺序)销毁所有服务,子容器先于父容器销毁。循环依赖在构造时就被 Graph 算法拦截,dispose 时不会出现死锁。
在 agent-core 和 agent-core-v2 中的应用
V1 使用 registerSingleton 在模块加载时注册所有服务描述符,启动时通过 getSingletonServiceDescriptors() 收集并构建根容器。V2 则在此基础上引入了 registerScopedService 机制,按 LifecycleScope(App / Session / Agent)三层分级注册。
V2 中 DI x Scope 架构的演进
V2 的 Scope 类是 DI 容器和作用域的融合:
arduino
enum LifecycleScope {
App = 0, // 应用级:配置、遥测、持久化引擎
Session = 1, // 会话级:LLM Provider、会话上下文
Agent = 2, // 智能体级:循环、工具注册表、提示词
}
// Scope 树:
// App Scope
// ├── Session Scope (session-1)
// │ ├── Agent Scope (agent-1)
// │ └── Agent Scope (agent-2)
// └── Session Scope (session-2)
关键特性:
- 子 Scope 只能向更深层级创建 :
kind <= this.kind时抛出异常,防止架构退化为扁平化 - Dispose 级联:父 Scope dispose 自动销毁所有子 Scope
- 服务注册按 Scope 隔离Agent 级服务只对当前智能体可见
这个设计使 kimi-code 从单体 Agent 演进为可以同时运行多个隔离会话、每个会话又能运行多个子智能体的多租户系统------而容器隔离是这一切的基石。
2. LLM Provider 抽象设计(kosong)
面临的问题
kimi-code 需要支持多种 LLM 后端:Kimi 自家的 API、OpenAI(Chat Completions 和 Responses 两种风格)、Anthropic(Messages API)、Google GenAI 等。直接调用各 SDK 会导致:
- 每个提供商的 API 语义差异(工具调用格式、流式事件、错误码)泄漏到业务层
- 更换模型需要修改业务代码
- 无法统一处理重试、取消、流水线
核心设计:统一的 ChatProvider 接口
php
interface ChatProvider {
readonly name: string; // 后端标识,如 "kimi", "anthropic"
readonly modelName: string; // 上游 API 使用的模型名
readonly thinkingEffort: ThinkingEffort | null;
readonly maxCompletionTokens?: number;
generate(
systemPrompt: string,
tools: Tool[],
history: Message[],
options?: GenerateOptions,
): Promise<StreamedMessage>;
withThinking(effort: ThinkingEffort): ChatProvider;
withMaxCompletionTokens?(max: number, opts?: MaxCompletionTokensOptions): ChatProvider;
}
不可变配置模式
withThinking() 和 withMaxCompletionTokens() 不修改当前实例,而是返回一个浅拷贝 新实例。这个设计的意图是:
- 请求安全:创建 provider 实例的基配置是一个稳定的 baseline,每次调用衍生一个临时配置,不会污染下一条请求
- 共享传输层:拷贝实例共享 HTTP client(实现中明确禁止替换或变更),避免重复建立连接
- immutable-like:在异步并发的场景下避免了状态竞争
generate() 函数的编排逻辑
generate() 不是一个简单的方法调用,而是一个精细的编排器:
- Pre-flight abort 检查:在发出请求前验证 signal 是否已中止
- Deferred tools 剥离 :标记为延迟的工具不在请求的
tools[]中发送,只通过消息级tools声明传递------保持 prompt caching 稳定性 - 流式合并 :consecutive compatible parts 使用
mergeInPlace合并在内存中,避免内存膨胀 - 并行工具调用索引路由 :通过
_streamIndex+toolCallIndexMap正确路由交错到达的工具调用参数片段 - 解码阶段记账 :精确分开
serverDecodeMs(等待下一个 part 的时间)和clientConsumeMs(处理每个 part 的时间),让性能诊断有据可循 - Tail abort 检查:流结束时再次检查中止信号
多供应商适配矩阵
providers 目录下每个文件对应一个后端:
| 文件 | 后端 | 适配要点 |
|---|---|---|
kimi.ts |
Kimi / KFC | 原生 Kimi 协议,x-trace-id 链路追踪 |
openai-legacy.ts |
OpenAI Chat Completions | SSE 流式解析,tool_choice 映射 |
openai-responses.ts |
OpenAI Responses API | 新协议,tool_calls finish reason 变为 completed |
anthropic.ts |
Anthropic Messages | required max_tokens,pause_turn 语义 |
google-genai.ts |
Google GenAI | REST 直接调用,tool_calls finish reason 变为 completed |
每个适配器负责将统一的 Message / Tool 类型转换为上游 API 的 wire format,再将流式响应映射回统一的 StreamedMessagePart。
FinishReason 归一化
不同提供商的停止原因被归一化为统一的枚举:
go
type FinishReason =
| 'completed' // 正常完成
| 'tool_calls' // 暂停等待工具调派(Chat Completions / Anthropic 专有)
| 'truncated' // token 预算耗尽
| 'filtered' // 内容过滤器拦截
| 'paused' // Anthropic pause_turn
| 'other'; // 其他已知但未归类的信号
ModelCapability 声明矩阵
每个模型的能力通过 ModelCapability 字典预先声明:
typescript
interface ModelCapability {
readonly image_in: boolean;
readonly video_in: boolean;
readonly audio_in: boolean;
readonly thinking: boolean;
readonly tool_use: boolean;
readonly max_context_tokens: number;
readonly max_input_tokens?: number;
readonly dynamically_loaded_tools?: boolean;
}
业务代码在构建请求前通过 getModelCapability(provider, model) 查询能力矩阵,对不支持图像或音频的模型提前拦截,而不是把请求发出去再等上游报错。未知模型返回冻结的 UNKNOWN_CAPABILITY 哨兵值(全 false,max_context_tokens: 0)。
错误分类体系
错误不是简单的 Error------它们有恢复策略的含义:
arduino
ChatProviderError // 基础类型
├── APIConnectionError // 网络故障 → 可重试(退避后)
├── APITimeoutError // 超时 → 可重试
├── APIStatusError // HTTP 错误
│ ├── APIContextOverflowError // 413 → 需要 compact 后重试
│ ├── APIRequestTooLargeError // 413 → 不可重试(需要缩小媒体)
│ └── APIProviderRateLimitError // 429 → 等待 Retry-After 后重试
└── APIEmptyResponseError // 空响应 → 不可重试
每个错误都携带 retryAfterMs(从 HTTP Retry-After 头解析)和 traceId(从 Kimi/KFC 的 x-trace-id 头解析),重试循环可以基于错误类型和退避时间做出智能决策。
kosong 让 kimi-code 的 Agent 逻辑与具体 LLM 提供商完全解耦。无论是内部 Kimi 模型还是第三方 API,Agent 看到的都是同一个
ChatProvider.generate()契约。
3. Transcript 四层数据模型
面临的问题
智能体的对话过程不是简单的「用户问了什么、AI 回了什么」。一个真实的 Agent 对话包含:
- 多轮 turn(每轮有多个 step,每个 step 包含多个 frame)
- 工具调用的增量文本流(逐字到达的 bash 输出)
- 后台任务、审批交互、计划状态、todo 列表
- 多个订阅者需要不同粒度的更新(UI 需要「逐字增量」,日志需要「完整 turn」)
如果用一个 JSON 对象序列化整个对话状态,每次更新都需要全量传输和渲染,性能无法接受。
四层架构
┌─────────────────────────────────────────────────────┐ │ L4 Views --- 渲染器注册表(框架无关) │ │ ViewRegistry: toolRenderers, inputRenderers, │ │ markerRenderers --- key → component lookup │ ├─────────────────────────────────────────────────────┤ │ L3 Subscriptions --- 粒度过滤器 │ │ off / turn / block / delta --- 按需订阅 │ ├─────────────────────────────────────────────────────┤ │ L2 Ops --- 传输词汇 (idempotent upsert + append) │ │ turn.upsert / step.upsert / frame.upsert / │ │ append / marker.upsert / interaction.upsert / ... │ ├────────────────────────────────────────────────────┤ │ L1 Store --- 内存状态容器(AgentTranscript) │ │ items\[\] + tasks/meta/todos/prompts 映射表 │ └─────────────────────────────────────────────────────┘
L1 Store:AgentTranscript
AgentTranscript 是单智能体粒度的内存状态容器,持有:
items[]:有序的时间线条目(turn / marker / taskRef)tasks:TaskId → TranscriptTask 映射interactions:InteractionId → TranscriptInteraction 映射attachments / todos / prompts / meta:其他实体集合
状态是复制即写(copy-on-write) 的:每次 apply() 产生新的 state 引用,已返回的 snapshot 不被后续操作污染。
L2 Ops:幂等操作词汇
这是传输层的词汇表。关键设计原则:
- **唯一非幂等操作是
append**(必须携带offset累积位置) - 所有 upsert 都是幂等的:重复接收不会产生副作用
- 按 producer 因果顺序执行:单代理的序列化通道保证收敛
bash
type TranscriptOperation =
| ResetOp // op: 'reset' --- 全量快照(首次加载或 gap 修复)
| TurnUpsertOp // op: 'turn.upsert' --- turn 头信息
| StepUpsertOp // op: 'step.upsert' --- step 头信息
| FrameUpsertOp // op: 'frame.upsert' --- 框架(工具调用/结果)
| AppendOp // op: 'append' --- 增量文本块(唯一的 non-idempotent)
| MarkerUpsertOp // op: 'marker.upsert'
| TaskUpsertOp // op: 'task.upsert'
| InteractionUpsertOp
| AttachmentUpsertOp
| TodoUpsertOp
| PromptUpsertOp
| TaskRefUpsertOp;
wire.jsonl 作为单一真相源
所有 L2 ops 以 append-only JSONL 格式写入磁盘。这是可审计、可重放、可跨会话恢复的持久化层。
L3 Subscriptions:按粒度过滤
订阅者可以选择关注的粒度级别:
off:不订阅turn:仅在 turn 级别变化时通知block:文本块级别delta:逐字增量(用于 UI 流式渲染)
L4 Views:框架无关的渲染器注册表
ViewRegistry<C> 是一个 key-based 的渲染器查找器,完全不依赖任何 UI 框架。C 是泛型参数,可以是 Vue 组件、React 组件或 Ink 渲染器:
php
class ViewRegistry<C> {
registerTool(key: string, renderer: C): this;
registerInput(originKind: string, renderer: C): this;
registerMarker(marker: string, renderer: C): this;
resolveTool(frame: ToolCallFrame): C | undefined; // frame.view ?? frame.name
resolveInput(origin: TurnOrigin): C | undefined; // origin.kind
resolveMarker(marker: string): C | undefined; // marker key
}
操作批处理序列合约
ops 信道需要保证单调递增的 seq 值。消费者通过 since_seq 发起 catch-up 请求获取错过的 ops。这是服务端推送和客户端拉取的边界------服务端负责维护 seq 递增,客户端负责检测 gap 并发起重置。
历史重建:groupTurns + foldFacts
从 L2 ops 重建可读的对话历史需要两个步骤:
- groupTurns:将 flat ops 序列重组为 turn → step → frame 的层级结构
- foldFacts:将分散的工具调用和工具结果折叠为可渲染的对话块
Transcript 的四层模型使 kimi-code 能够在服务端以 ops 粒度推送更新、在客户端按需重建视图------既不丢失流式渲染的实时性,又保持了数据的完整性和可审计性。
4. 执行环境抽象(kaos)
面临的问题
kimi-code Agent 需要在多种环境中运行:本地开发机、SSH 远程服务器、容器(通过 ACP 协议连接)。每个环境有不同的文件系统语义、进程管理方式和路径规范。如果 Agent 代码直接调用 fs.readFileSync 或 child_process.exec:
- 不可切换:本地写死的调用无法重定向到远程环境
- 不可测试:单元测试需要真实的文件系统
- 不可组合:无法在同一进程中同时操作多个环境
核心设计:Kaos 接口
Kaos(Kimi Agent Operating System)是一套统一的文件系统和进程操作抽象:
scss
interface Kaos {
readonly name: string; // 环境标识:"local", "ssh:host"
readonly osEnv: Environment; // OS/Shell 探测结果
// 路径操作
pathClass(): 'posix' | 'win32';
normpath(path: string): string;
gethome(): string;
getcwd(): string;
// 文件操作
readBytes(path: string, n?: number): Promise<Buffer>;
readText(path: string, opts?: { encoding?, errors? }): Promise<string>;
writeBytes(path: string, data: Buffer): Promise<number>;
writeText(path: string, data: string, opts?): Promise<number>;
// 目录操作
stat(path: string, opts?): Promise<StatResult>;
iterdir(path: string): AsyncGenerator<string>;
mkdir(path: string, opts?): Promise<void>
// 进程执行
exec(...args: string[]): Promise<KaosProcess>;
execWithEnv(args: string[], env?: Record<string, string>): Promise<KaosProcess>;
// 不可变派生
withCwd(cwd: string): Kaos;
withEnv(env: Record<string, string>): Kaos;
}
三种实现
| 实现 | 环境 | 底层机制 |
|---|---|---|
LocalKaos |
本地开发机 | Node.js fs + child_process |
SSHKaos |
SSH 远程服务器 | SSH2 客户端 + SFTP 协议 |
ACPKaos (acp-adapter) |
ACP 客户端环境 | Agent Client Protocol(通过 kaos-acp.ts) |
AsyncLocalStorage 上下文传递
这是实现「同一份 Agent 代码在不同环境可运行」的关键:
javascript
// 底层:AsyncLocalStorage 绑定当前 kaos 实例
const kaosStorage = new AsyncLocalStorage<Kaos>();
export function getCurrentKaos(): Kaos {
const store = kaosStorage.getStore();
if (store === undefined) throw new KaosError('No Kaos is bound...');
return store;
}
export function runWithKaos<T>(kaos: Kaos, fn: () => T): T {
return kaosStorage.run(kaos, fn);
}
// 上层:模块级的便捷函数(自动从当前上下文获取 kaos)
export function readText(path: string, opts?): Promise<string> {
return getCurrentKaos().readText(path, opts);
}
export function exec(...args: string[]): Promise<KaosProcess> {
return getCurrentKaos().exec(...args);
}
Agent 代码调用 import { readText, exec } from '@moonshot-ai/kaos' ------完全不需要知道当前是在本地还是远程。环境切换通过外层 runWithKaos(localKaos, () => agent.run()) 或 runWithKaos(sshKaos, () => agent.run()) 完成。同一进程可以有多个并发上下文,每个使用不同的 Kaos 实例,互相隔离。
不可变派生模式
类似 kosong provider,withCwd() 和 withEnv() 返回新实例。这让工具的沙箱化变得简单:给某个工具调用创建一个 /tmp/workspace 子目录的 kaos 视图,它只在这个请求的上下文中有效。
Kaos 抽象让 kimi-code 的 Agent 代码实现了「编写一次,处处运行」------本地开发、SSH 远程、ACP 客户端三种环境共用同一套 Agent 逻辑,只在初始化时注入不同的 Kaos 实例。
5. V1 → V2 架构演进
V1 的问题
agent-core V1 的根基是一个巨大的 Agent 类------它是所有逻辑的中心,集成了配置、LLM 调用、工具执行、权重管理、会话状态、对话循环、context 构建等数十种职责。
这导致了几个根本性问题:
- 紧耦合 :Agent 直接
import所有依赖模块的实例,替换任何一个实现都需要侵入 Agent 内部 - 难以测试:单元测试需要构造完整的 Agent 实例,Mock 依赖需要 mock 模块导入
- 难以多租户:同时运行两个 Agent 实例时,全局单例会互相干扰
- 用户代码侵入:面向用户的 CLI 代码和核心 Agent 逻辑混杂在同一个类中
V2 的目标
V2 重新设计了整个架构,核心原则是:
- DI x Scope:所有服务通过 DI 容器接入,按生命周期 Scope 隔离
- 服务化:每个职责成为一个独立服务,通过接口契约定义边界
- 可测试:通过容器 stub 可以在单元测试中注入 mock 依赖
- 多租户隔离:Session 和 Agent 级别的 Scope 天然隔离不同实例的状态
V2 的域划分
V2 按业务域组织代码,每个域有清晰的职责边界:
| 域 | 路径 | 职责 | 示例服务 |
|---|---|---|---|
| _base | _base/ |
基础设施(DI 容器、日志) | InstantiationService, LogService |
| agent | agent/ |
智能体核心行为 | LoopService, ToolExecutor, ContextMemory |
| app | app/ |
应用层(配置、遥测、插件、auth) | ConfigService, PluginService, TelemetryService |
| session | session/ |
会话管理(生命周期、状态) | AgentLifecycleService, SessionMcpService |
| kosong | kosong/ |
LLM 供应商抽象 | ProviderService, ModelService, Generate |
| os | os/ |
操作系统接口 | HostEnvironmentService, HostFsService |
| persistence | persistence/ |
持久化层 | FileStorageService, AppendLogStore |
| tool | tool/ |
工具注册和执行 | ToolRegistryService, ToolContributions |
| wire | wire/ |
记录和回放 | WireService, WireMigration |
kap-server:V2 引擎的对外接口
kap-server 是 V2 架构的 HTTP/WebSocket 网关。它启动 V2 Scope 树,将外部请求映射到 V2 服务调用:
- REST API → SessionLifecycleService(创建/恢复会话)
- WebSocket → SessionScope(维持会话级别的状态和订阅)
- ACP Bridge → 通过 kaos ACP 适配器在远程环境执行
klient:传输无关的类型化切面
klient 提供客户端侧的类型安全 API。它抽象了底层传输层(HTTP / WebSocket),只暴露领域概念:Session, Message, AgentRun 等。上层 UI(如 kimi-web、vscode 插件、终端 TUI)全部依赖 klient 而不是直接打 HTTP 请求。
演进策略:双引擎共存
V1 和 V2 不是一夜之间切换的。项目通过以下策略平滑过渡:
- 双引擎共存:V1 和 V2 的 Agent 在同一个进程中并行运行,通过路由层分流
- 逐步迁移:工具注册、权限管理、会话管理等功能逐个迁移到 V2 域
- 兼容层 :
migration-legacy包提供 V1 ↔ V2 的数据格式转换 - Session Legacy Service:V2 可以读取和恢复 V1 创建的历史会话
V1 → V2 不仅是代码重构,更是一次架构哲学上的演变:从「一个大类包办一切」变为「通过 DI 容器组合服务,通过 Scope 隔离实例,通过接口契约定义边界」。这个转变让 kimi-code 从单一 CLI 工具成长为可以支撑 Web、VSCode 插件、SSH 远程等多形态产品的平台级引擎。
总结
这五个架构决策不是孤立存在的------它们形成了一个相互支撑的网络:
┌──────────────┐ 注入 ┌──────────────┐ │ DI 容器 │ ◄───────── │ Kaos 抽象 │ │ + Scope 树 │ │ (执行环境) │ └──────┬───────┘ └──────────────┘ │ │ 管理生命周期 提供执行能力 │ │ ┌──────────┴──────────┐ ┌─────────┴──────────┐ │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ LLM Provider │ │ Agent V2 │ │ Transcript │ │ (kosong) │ │ 引擎 │ │ 四层模型 │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ 统一 LLM 服务化编排 状态快照 供应商接口 多租户隔离 增量同步
- DI 容器提供了服务组合和生命周期管理的骨架
- kosong 让 Agent 逻辑与具体 LLM 提供商解耦
- Transcript解决了对话状态的流式同步和历史重建问题
- Kaos 让同一份 Agent 代码在不同执行环境中无缝运行
- V2 架构将所有组件整合为一个可测试、可扩展、可多租户的平台
理解这些架构决策,就理解了 kimi-code 如何从一个实验性的 AI CLI 工具,演化为支撑 Kimi 全线产品的 Agent 引擎平台。下一篇将深入 Agent 类的内部------这个引擎的编排者如何协调所有服务完成一轮对话。