可重放的 多 agent 内核
Kimi Code 是 Moonshot AI 开源的 TypeScript Monorepo 编程 agent:一个 Session 持多个隔离 Agent、每个动作写成可重放的 Wire Record、MCP 一等公民、Kosong 把 6 类 LLM 协议压平成一个接口。
# curl -fsSL https://kimi.moonshot.cn/install.sh | bash · Node ≥ 24
事实是日志,状态是重演
理解 kimi-code 的支点:系统里没有一个可变的「会话状态对象」被直接持久化。每个状态变化都先写成一行结构化 JSONL,内存状态是把日志重演出来的结果。
AgentRecord(wire.jsonl)
每个动作 append 一条记录:turn.prompt / config.update / usage.record / context.append_message / full_compaction.complete … 20+ 种类型,每行一个 JSON。这是真正落盘的东西。
内存状态
重启时 AgentRecords.restore() 把 _restoring 置 true,逐行回放 restoreAgentRecord。恢复期硬契约:不得有任何副作用——不调 LLM、不跑工具、不碰文件,纯粹把日志重演成内存状态。
migrateWireRecord 逐条迁移(v1.1 / v1.2 迁移器在 records/migration/)。apps/vis 调试器就吃这个格式。一条单向依赖链
kimi-code 是清晰的单向依赖:从 CLI 到执行环境,每层只依赖它下面那层。点击任意一层看它的职责与边界。
把一次 turn 拆给你看
turn loop 被刻意拆成两层无状态函数,状态全部外置到 Agent,所以 loop 本身可独立单测。按下播放,看 runTurn / executeLoopStep 逐帧收敛。
不是终止
stopReason==='tool_use' 时 continue 进下一 step;只有非工具停止才进入「要不要收尾」判断。这是两层 loop 的分界点。
立即记账
usage 在 LLM 返回后立即记账,而不是等工具跑完——即使工具执行期间 abort,已消费 token 也不丢,计费/预算永远准确。
turn 进行中插话
turn 正在跑时新输入走 steer() 被 buffer,turn 间隙注入;无活跃 turn 时 steer 等价 prompt 直接 launch。
并行,但不无脑并行
同一 step 里 LLM 可能一次发多个 tool_call。ToolScheduler 不是全并行,而是基于 资源冲突检测——读同一文件可并行,写同一文件排队。
// 同 step 多个 tool_call:按资源冲突决定并行 / 排队
for (const call of batch) {
const conflict = ToolAccesses.conflict(call.access, running);
if (conflict) {
queuedTasks.push(call); // 写同一文件 → 排队
} else {
running.add(call.access);
runToolCall(call).finally(() => {
running.delete(call.access);
startQueuedTasks(); // 唤醒被挡住的任务
});
}
}
isError: true 的 tool result 回给 LLM——而不是把错误塞进正常 content。ToolManager 关注点
| 字段 / 能力 | 作用 |
|---|---|
name | 模型可见的工具名(唯一) |
parameters | schema 自动校验入参 |
execute | 干活;带 signal 与流式 onUpdate |
access | 声明读/写资源,供冲突检测 |
ToolScheduler | 无冲突并行、有冲突排队唤醒 |
tool_execution_end 按完成顺序发出,但持久化的 toolResult 消息仍按 assistant 源顺序——UI 看到的进度与落盘的事实分离。| 失败场景 | 表现 / 归一 | 处理 |
|---|---|---|
| LLM 网络抖动 | isRetryableGenerateError | chatWithRetry 指数退避(300ms→5s,×2,≤3 次) |
| turn 中途 abort | signal.aborted | 返回 {stopReason:'aborted'},usage 已记不丢 |
| 步数超限 | max_steps | createMaxStepsExceededError → turn.interrupted |
| 安全过滤 / 截断 | filtered / max_tokens | 非工具停止,不继续循环,提前收尾 |
| step.end 前 abort | envelope 不完整 | runTurn 捕获 → 发 turn.interrupted |
一个 Session,N 个隔离 Agent
kimi-code 的「多 agent」不是切一个大上下文,而是真正实例化多个独立 Agent 对象。Session 是容器,持有一张 agents: Map<id, Agent>。
窗口零污染
每个 Agent 自带独立 Kaos / ToolManager / PermissionManager / AgentRecords(独立 wire.jsonl)/ context.history。子 agent 读 50 个文件,不污染主 agent 窗口——主只收一段摘要。
explore 注入仓库快照
explore agent 从空上下文出发,runChild 在 prompt 前注入 collectGitContext()(当前分支、最近 commit、改动文件),让它「落地即知身在何处」。别的 subagent 没有这个待遇。
max_tokens 处停 → 拒绝把半截结果当成功;并发 resume 同一子 → 抛 already running;归属校验 parentAgentId !== ownerAgentId → 子不能被别的父认领;resume 时强制子模型对齐父当前模型。把 6 类协议压成一个接口
kosong 是纯粹的数据转换层,不含业务逻辑——这是它能独立发布、独立测试的原因。每个 adapter 把自家 SDK 的流式事件,翻译成统一的 StreamedMessagePart 序列。
| Provider | 底层 SDK | 关键差异 / 坑 |
|---|---|---|
kimi.ts | openai(Moonshot 兼容端点) | 支持 uploadVideo();强制只发 max_completion_tokens(发 max_tokens 会被 reasoning 预算吃掉 → 空响应) |
anthropic.ts | @anthropic-ai/sdk | 原生 thinking block;pause_turn → 归一为 paused;refusal → filtered |
openai-common.ts | openai(共享转换) | 承载 convertContentPart / toolToOpenAI / finish_reason 归一,被 kimi & legacy 复用 |
openai-responses.ts | openai(Responses API) | 新 Responses 协议,事件流形状与 Chat Completions 不同 |
google-genai.ts | @google/genai | 同时服务 google-genai 与 vertexai 两种 type;Gemini 专用 STOP/MAX_TOKENS 归一 |
export type FinishReason =
| 'completed' // stop · end_turn · STOP
| 'tool_calls' // tool_use · tool_calls
| 'truncated' // length · max_tokens · MAX_TOKENS
| 'filtered' // refusal / 安全策略
| 'paused' // Anthropic pause_turn(仅此家)
| 'other'; // 未分类原生值
交错 delta 的难点
generate() 的核心难点是并行工具调用的参数 delta 会交错:tool-call A 的参数片段和 B 的可能交替到达。
- toolCallIndexMap碎片路由回正确 toolCall
- onToolCall仅在流结束、参数合并后触发
- isEmptyMessage无 text 无 tool → APIEmptyResponseError
reasoning_content 上、content 为空 → 正是 isEmptyMessage 这道闸要拦的 200 空响应。13 种事件 × 外部命令门控
HookEngine 把外部 shell 命令挂到 agent 生命周期。注册时按 event 归桶,每个 HookDef 带可选 matcher 正则,触发时过滤。
阻塞型
任一命令返回 action:'block' 就否决该操作——这是权限门控、危险命令拦截的实现。
通知型
用于 PostCompact / SubagentStop 这类不要结果的场景,promise 进 pendingTriggers 但不 await。
| 事件 | 语义 | 归约 |
|---|---|---|
PreToolUse | 工具执行前,可拦截 | block / 放行 |
PostToolUse · PostToolUseFailure | 工具完成 / 失败后 | 观察 |
UserPromptSubmit | 用户提交 prompt | 观察 / 注入 |
Stop · StopFailure | turn 收尾 / 失败收尾 | 观察 |
SessionStart · SessionEnd | 会话首尾 | 观察 |
SubagentStart · SubagentStop | 子 agent 首尾 | fire-and-forget |
PreCompact · PostCompact | 压缩前后 | 观察 |
Notification | 桌面通知等 | — |
trigger 外层 .catch(() => [])、matcher 正则编译失败返回 false、监听器异常被吞——hook 是「增强」而非关键路径,绝不能因为一个坏 hook 让 agent 卡死。匹配命令并行执行(Promise.all),默认 30s 超时。每个动作都是一行 JSONL
Wire Records 是全部持久化的 agent 状态——append-only。配上自动压缩:用量逼近上限时把旧消息总结成 summary,保留最近若干步。
AgentRecord 记录类型(节选)
| type | 记录什么 |
|---|---|
turn.prompt · turn.steer | 一次用户输入 / 插话 |
config.update | model / cwd / thinking 变更 |
context.append_message | 一条消息入 history |
context.append_loop_event | tool.call / tool.result |
usage.record | 一次 LLM 用量(立即记账) |
full_compaction.begin / complete | 压缩起止 + 前后 token |
permission.set_mode · tools.register_user_tool | 权限 / 工具注册 |
压缩阈值(DefaultCompactionStrategy)
何时压
用量达上下文 85% 触发;reserved = 50_000,used + reserved ≥ max 也触发;一个 turn 内最多压 3 次,超了抛 CONTEXT_OVERFLOW。
怎么切
保护最近 3 步 + 尾部连续 user 消息;只在角色边界切分,绝不拆散 tool_call 与它的 tool_result。压缩 worker 跑完逐条比对 history 是否被改过,变了就 cancel() 放弃。
精确恢复
恢复到包括 pending turn 的状态;恢复期零副作用。
协议演进
migrateWireRecord 逐条迁移;v1.1 / v1.2 迁移器在 records/migration/。
可视化调试
apps/vis 直接吃 wire 格式,回放一次会话的全过程。
会话即遥测
因为每个动作都已是结构化 wire 记录,可观测性几乎是「免费」的:wire.jsonl 本身就是一条完整的、可重放的执行轨迹。
{"type":"config.update","modelAlias":"kimi-k2"}
{"type":"turn.prompt","input":[{"type":"text","text":"修复登录 bug"}]}
{"type":"context.append_loop_event","event":{"kind":"tool.call","name":"edit"}}
{"type":"usage.record","usage":{"input":8200,"output":410}}
{"type":"full_compaction.complete","tokensBefore":95000,"tokensAfter":31000}
一份 wire = 一条轨迹
不需要外接 APM 也能回答「这次会话花了多少 token、调了哪些工具、何时压缩」。
- turn.prompt一次用户输入
- context.append_loop_event每次工具调用 / 结果
- usage.record每次 LLM 用量
- full_compaction.*每次压缩前后
- config.update每次配置变更
三十秒跑起第一个会话
单 Binary 发布,零配置起步。装好就能在 TUI 里对话,并用 /mcp-config 管理 MCP 服务器。
# macOS / Linux
curl -fsSL https://kimi.moonshot.cn/install.sh | bash
# Windows (PowerShell)
irm https://kimi.moonshot.cn/install.ps1 | iex
# 启动 TUI
kimi-code # 进入交互式会话
kimi-code --resume # 从 wire 恢复上次会话
# 在对话里直接管理 MCP 服务器
/mcp-config add github # 添加
/mcp-config login github # 触发 OAuth 浏览器授权
/mcp-config list # 查看五态:connected /
# needs-auth / failed / …
# 切模型(写入 wire: config.update)
/model kimi-k2
needs-auth,用户即可见 /mcp-config login 的可操作提示。随手翻的一页纸
把高频概念、记录类型、provider 与源码位置压在一处——卡壳时回这里。
核心概念
- Session
- 会话容器,持 N 个 Agent
- Agent
- 独立 Kaos/Tools/Permission/wire
- TurnFlow
- activeTurn + steerBuffer
- runTurn
- 外层 turn 收敛
- executeLoopStep
- 内层单 step 原子操作
- SubagentHost
- spawn coder/explore/plan
Wire 记录类型
- turn.prompt / steer
- 用户输入 / 插话
- config.update
- model / cwd / thinking
- context.append_*
- 消息 / loop event
- usage.record
- LLM 用量(立即)
- full_compaction.*
- 压缩起止
- permission.set_mode
- 权限模式
Kosong Provider
- kimi.ts
- Moonshot(uploadVideo)
- anthropic.ts
- thinking / pause_turn
- openai-common
- 共享转换
- openai-responses
- Responses API
- google-genai
- genai + vertexai
压缩阈值
- triggerRatio
- 0.85
- reserved
- 50_000
- maxRecentSteps
- 3
源码地图 · packages/
- agent-core/agent/turn loop · Agent
- agent-core/session/Session · subagent
- agent-core/records/wire · migration
- agent-core/mcp/connection · oauth
- agent-core/hooks/HookEngine
- kosong/src/ChatProvider
- kaos/src/Local / SSH