Kimi CLI:从零看懂一个多 Agent 系统怎么搭
零、这篇要带你到哪
读完这篇,你应该能回答这几个问题,并且能在自己的项目里动手实现:
- 为什么一个 Agent 干活干着干着就「变笨」了?根因是什么?
- 「派一个子 Agent 去干」到底解决了什么问题?它不是万能的,边界在哪?
- 一个子 Agent 从被创建到把结果交回来,中间经过哪些步骤?每一步为什么必须有?
- 生产环境里,这套机制会在哪些地方翻车?别人是怎么堵上的?
需要的前置知识:知道什么是 LLM 的「上下文窗口」、什么是「工具调用(function calling)」。除此之外不需要别的。看不懂 Python 也没关系,代码我会逐段翻译成人话。
为什么拿 Kimi CLI 当样本:这是目前唯一一个一线大厂把「通用 Agent 产品的子 Agent 调度代码」完整开源出来的项目。Manus、Devin 这些都是闭源的,只能靠泄露的提示词猜;Kimi 是 Apache-2.0 协议摆在 GitHub 上,每一行都能自己去核对。
拿代码:
git clone --depth 1 https://github.com/MoonshotAI/kimi-cli.git
| 项 | 值 |
|---|---|
| 仓库 | MoonshotAI/kimi-cli |
| Star | 11,218 |
| 协议 | Apache-2.0(可商用、可改) |
| 版本 | pyproject.toml 里 version = "1.49.0" |
| 体量 | src/ 下 52,049 行 Python |
| 拉取时间 | 2026-08-19 |
这个领域三个月一代,你看到时行号大概率已经变了。用文件名和函数名定位,不要用行号定位。
一、先看一个真实会翻车的场景
假设你让一个 Agent 干这么件事:
「把这个仓库的鉴权模块搞清楚,然后把登录超时的那个 bug 修了。」
它会怎么干?大概是这样:
grep -r "auth"—— 返回 200 行文件路径- 读
auth/middleware.py—— 800 行代码进上下文 - 读
auth/session.py—— 600 行进上下文 - 读
config/settings.py—— 400 行进上下文 - 发现引用了
utils/token.py,再读 —— 300 行 git log看历史 —— 又是几百行- ……找了十几个文件之后,终于明白了
- 开始改代码
问题出在第 8 步。这时候上下文里堆了什么?
十二个文件里,真正对「修这个 bug」有用的可能只有 30 行。 剩下 14 万多 token 全是「我为了找到这 30 行,路上翻过的东西」。
这会带来三个后果,一个比一个严重:
后果一:钱。 上下文是每一轮都要重新发给模型的。你改代码要跟模型来回 20 轮,那 14 万 token 就要重复计费 20 次。
后果二:变笨。 这是最要命的。模型对上下文中间部分的注意力会显著下降(业界叫 lost-in-the-middle)。你在第 3 步读到的关键信息,到第 30 步的时候它可能已经「看不见」了。表现出来就是:明明前面读过这个函数,后面却凭空编了一个不存在的参数。
后果三:直接撞墙。 上下文满了,任务还没干完,只能中断或者强行压缩,压缩就会丢信息。
这就是所有多 Agent 系统真正要解决的第一个问题。 不是什么「让多个 AI 协作产生智能涌现」——是很朴素的:探索过程产生的垃圾,不该留在干活的那个上下文里。
二、几个朴素的想法,为什么都不够
在跳到「派子 Agent」这个答案之前,先看看更简单的办法为什么不行。这一节很重要,因为它决定了你什么时候不需要多 Agent。
想法一:读文件的时候截断,只取前 100 行
读 auth/middleware.py → 只把前 100 行放进上下文
为什么不够:你要找的东西经常不在前 100 行。而且截断是盲目的——模型不知道自己漏了什么,会基于残缺信息瞎猜。
不过要说明:这个办法在某些场景是够用的。 如果你的工具输出天然就是「越靠前越重要」(比如搜索结果排序),截断就是对的,别上多 Agent。
想法二:读完就从上下文里删掉
读完 auth/middleware.py,提取要点后,把原文从历史里删除
为什么不够:两个原因。
第一,你怎么知道哪些是「要点」?删之前得先判断,判断就得再调一次模型,成本没省下来。
第二,也是更隐蔽的:删东西会让 KV-cache 失效。 模型推理时会缓存前缀的计算结果,缓存命中的部分便宜十倍左右。你一旦从中间删掉一段,后面所有内容的位置都变了,缓存全废。本来是省钱,结果更贵。
想法三:换个上下文更大的模型
为什么不够:上下文从 200K 换到 1M,成本线性上涨,但 lost-in-the-middle 问题不会因为窗口变大而消失——反而更严重。大窗口买的是「能装下」, 不是「能记住」。
那正确的思路是什么
回到问题本身:探索产生的垃圾,不该留在干活的上下文里。
前三个想法都在纠结「怎么把垃圾从上下文里弄出去」。换个角度:
能不能让垃圾根本就不进来?
具体做法是:另外开一个全新的对话,让它去翻那十二个文件,翻完只把一句话结论带回来。
这就是「子 Agent 委派」。它本质上不是「多个智能体协作」,而是一种上下文管理技术。 想通这一点,后面所有设计就都好理解了——每一个设计都是在服务「隔离要彻底」和「结论要可用」这两件事。
顺带划一下边界:如果你的子任务本身就要往同一份东西里写(比如两个子 Agent 同时改一个文件),那隔离反而有害——它们看不见对方的决定,会打架。这条边界很重要,08 那篇 会专门讲。
三、把几个名词讲清楚
在读代码之前,先把 Kimi CLI 里的几个名词对上号。不然打开源码会被 Soul、Runtime、LaborMarket 这种名字搞晕。
一个生活化的类比
把整个系统想象成一个装修工程:
| 代码里的名字 | 类比 | 大白话 |
|---|---|---|
Runtime | 整个工地 | 这次任务的全局环境:用哪个模型、工作目录在哪、配置是什么 |
Agent | 一份岗位说明书 | 「你是干嘛的、能用哪些工具、用哪个模型」——是配置,不是活的 |
Soul | 按说明书上岗的那个人 | 拿着说明书 + 一本工作日志,真正在循环干活的实体 |
Context | 工作日志本 | 这个人的完整对话历史,存成一个文件 |
LaborMarket | 劳务市场 | 登记了「有哪些工种可雇」的注册表 |
AgentTool | 打电话叫人的那部电话 | 模型能调用的工具,一调就雇一个子 Agent 来 |
LaborMarket(劳动力市场)是 Kimi 源码里真实的类名,不是我编的比喻。这个命名本身就说明了他们的心智模型:主 Agent 是雇主,子 Agent 类型是可雇佣的工种。
它们之间的关系
这张图里最关键的是右下角那个 Context。 子 Agent 有自己的一本日志,跟主 Agent 的日志是两个不同的文件。这就是「上下文隔离」的物理实现——不是靠约定,是靠文件系统。
什么叫「上下文隔离」
再具体一点。假设主 Agent 派了一个子 Agent 去查代码:
- 子 Agent 能看到什么:一段任务描述(「去搞清楚鉴权模块怎么工作的」)、它自己的系统提示词。看不到主 Agent 跟用户之前聊了什么。
- 主 Agent 能看到什么:子 Agent 的最后一条消息。看不到子 Agent 中间读了哪些文件、执行了什么命令。
Kimi 把这个约定直接写进了每个子 Agent 的提示词里:
All the `user` messages are sent by the main agent. The main agent cannot see
your context, it can only see your last message when you finish the task.
翻译过来:「你收到的所有消息都来自主 Agent。主 Agent 看不见你的过程,只能看见你最后说的那句话。」
这句话之所以要明明白白告诉模型,是因为它会影响模型的行为——知道「只有最后一句会被看到」,它才会认真写总结;不知道的话,它可能干完就回一句「好了」。
四、动手:一步步把这套机制搭出来
现在开始搭。每一步只加一个东西,加完立刻说清楚「不加会怎样」。
Step 1:先有一个能转的单 Agent 循环
在讨论子 Agent 之前,得先有一个 Agent。最朴素的循环就三件事:把历史发给模型 → 模型说要调什么工具 → 执行工具、把结果追加进历史 → 回到第一步。
Kimi CLI 的实现在 src/kimi_cli/soul/kimisoul.py:
step_no = 0
while True:
step_no += 1
# ── 2a. Step Guard ──
if step_no > self._loop_control.max_steps_per_turn:
raise MaxStepsReached(self._loop_control.max_steps_per_turn)
这几行在防什么:防止模型陷入死循环把钱烧光。没有这个上限,一个「读文件 → 发现不对 → 再读 → 还是不对」的模式可以无限转下去,而且每一轮都在正常调 API,不会报错,你不盯着就发现不了。
上限值定在哪?
class LoopControl(BaseModel):
max_steps_per_turn: int = Field(default=1000, ge=1, ...)
max_retries_per_step: int = Field(default=3, ge=1)
reserved_context_size: int = Field(default=50_000, ge=1000)
compaction_trigger_ratio: float = Field(default=0.85, ge=0.5, le=0.99)
四个数字,逐个解释:
| 参数 | 值 | 人话 |
|---|---|---|
max_steps_per_turn | 1000 | 用户说一句话,最多允许它连续调 1000 次工具 |
max_retries_per_step | 3 | 单步失败(网络抖动等)重试 3 次 |
compaction_trigger_ratio | 0.85 | 上下文用到 85% 就开始自动压缩 |
reserved_context_size | 50,000 | 永远给模型的输出留 5 万 token 空间 |
1000 这个数字说明了这个系统的定位:它假设的是「你说一句话,它自己跑几个小时」,不是聊天式的一问一答。作为对照,MiniMax 的 Mini-Agent 默认是 50 步,Manus 官方说他们平均一个任务约 50 次工具调用。
0.85 和 5 万这两个数字是配套的:不等塞满才压缩,因为压缩本身也要调模型、也要占空间。留出余量是为了「压缩这个动作本身别把上下文撑爆」。
到这一步为止,我们有了 一个能干活的单 Agent。它就是第一节里那个会翻车的家伙。
Step 2:加一个「派活」的工具
现在给模型一个新工具,让它能把活外包出去。工具就叫 Agent。
模型看到的工具长这样(参数定义在 src/kimi_cli/tools/agent/__init__.py):
class Params(BaseModel):
description: str = Field(description="A short (3-5 word) description of the task")
prompt: str = Field(description="The task for the agent to perform")
subagent_type: str = Field(default="coder", ...)
先只看这三个参数(后面还有几个,一步步加):
description:三五个词的任务简述,纯粹给人看进度用prompt:真正交给子 Agent 的任务描述subagent_type:要雇哪个工种,默认coder
这里有个容易忽略的点:prompt 是子 Agent 唯一的输入。主 Agent 跟用户聊了半天的上下文,子 Agent 一个字都看不到。所以这段 prompt 写得好不好,直接决定子 Agent 干得好不好。
Anthropic 在他们的多 Agent 系统博客里把这条列为头号经验:教会主 Agent 怎么写派活的任务描述,比什么都重要。描述不自包含,子 Agent 就会做重复劳动或者集体漏掉某一块。
调用后的完整链路是这样:
对比一下 Step 1:同样的任务,主上下文从 145K 降到 3K。这就是全部收益的来源。
Step 3:给子 Agent 一本自己的日志
上一步说「创建一个全新的对话」,具体怎么落地?Kimi 的做法是给每个子 Agent 实例分配一个磁盘目录。
@property
def root(self) -> Path:
return self._session.dir / "subagents"
def context_path(self, agent_id: str) -> Path:
return self.instance_dir(agent_id) / "context.jsonl"
def meta_path(self, agent_id: str) -> Path:
return self.instance_dir(agent_id) / "meta.json"
def prompt_path(self, agent_id: str) -> Path:
return self.instance_dir(agent_id) / "prompt.txt"
这几行在防什么:防止「隔离」只停留在口头约定上。历史落在不同文件里,就不可能出现「不小心把主上下文传给子 Agent」这种 bug——它们物理上就是两个文件。
跑起来之后,磁盘上长这样:
<会话目录>/
└── subagents/
├── a3f9c1d2/ ← 一个子 Agent 实例
│ ├── context.jsonl ← 它的完整对话历史(主 Agent 读不到)
│ ├── wire.jsonl ← 事件流,界面拿来显示进度
│ ├── meta.json ← 状态:idle / running / completed / failed / killed
│ ├── prompt.txt ← 启动时那段 prompt 的快照,纯为了排查问题
│ └── output/ ← 产出的文件
└── a7b2e004/ ← 另一个实例
└── ...
agent_id 的生成方式也有讲究:
agent_id = f"a{uuid.uuid4().hex[:8]}"
一个字母 a 加 8 位十六进制,比如 a3f9c1d2。
为什么不用完整 UUID:因为这个 ID 的使用者是模型。后面 Step 7 会讲到,模型需要把这个 ID 填回工具参数里去续跑某个子 Agent。完整 UUID 有 36 个字符,模型抄着抄着就抄错了。给模型看的标识符要短。
prompt.txt 这个文件也值得说一句:它对运行时毫无作用,纯粹是为了让你事后能回答「这个子 Agent 当时到底收到了什么任务」。做 Agent 系统一定要留这种快照,因为 prompt 是动态拼出来的,出问题时如果不知道模型实际看到了什么,根本没法排查。
Step 4:不是所有工种都该有所有权限
现在有了「派活」和「隔离」,但还有个问题:如果子 Agent 拿到和主 Agent 一样的全套工具,会出事。
具体来说:你派它「去搞清楚鉴权模块」,它读着读着觉得「这里有个 bug,我顺手改了吧」,于是在你不知情的情况下改了代码。你以为它只是去看看。
解法是按工种分配工具权限。Kimi 内建了三个工种,定义在 src/kimi_cli/agents/default/:
注意方向:是继承之后做减法,不是从零加法。 这样新增一个工具时,主 Agent 自动有,各工种自动继承,不会漏配。
以 explore(只读探索工种)为例:
agent:
extend: ./agent.yaml
system_prompt_args:
ROLE_ADDITIONAL: |
You are a codebase exploration specialist. Your role is EXCLUSIVELY to
search, read, and analyze existing code. You do NOT have access to file
editing tools.
allowed_tools:
- "kimi_cli.tools.shell:Shell"
- "kimi_cli.tools.file:ReadFile"
- "kimi_cli.tools.file:Glob"
- "kimi_cli.tools.file:Grep"
exclude_tools:
- "kimi_cli.tools.file:WriteFile"
- "kimi_cli.tools.file:StrReplaceFile"
- "kimi_cli.tools.agent:Agent"
这几行在防什么:注意它做了两件事 ,不是一件。
- 提示词里说「你没有编辑文件的工具」
- 工具列表里真的把写文件的工具删掉了
为什么要做两遍? 这是个非常实用的经验:
- 只做第 2 件(物理删掉工具):模型会困惑。它想改文件,发现没有这个工具,可能会转而用
Shell执行sed -i绕过去——你以为堵住了,其实没有。 - 只做第 1 件(提示词约束):模型大概率会听,但大概率不等于一定。任务一复杂,它就可能「为了完成任务」而违背约束。
- 两件都做:模型知道自己是只读的(所以会用只读的方式思考问题),同时物理上也做不到写。
这条经验可以推广:凡是「绝对不能做」的事,提示词和代码要各堵一遍。
三个工种的权限对比:
| 工种 | Shell | 读文件 | 写文件 | 搜网 | 派子 Agent | 适合干什么 |
|---|---|---|---|---|---|---|
coder | ✅ | ✅ | ✅ | ✅ | ❌ | 真正改代码的活 |
explore | ✅ 仅只读 | ✅ | ❌ | ✅ | ❌ | 快速摸清代码库 |
plan | ❌ | ✅ | ❌ | ✅ | ❌ |