Skip to main content

Kimi CLI:从零看懂一个多 Agent 系统怎么搭

零、这篇要带你到哪

读完这篇,你应该能回答这几个问题,并且能在自己的项目里动手实现:

  1. 为什么一个 Agent 干活干着干着就「变笨」了?根因是什么?
  2. 「派一个子 Agent 去干」到底解决了什么问题?它不是万能的,边界在哪?
  3. 一个子 Agent 从被创建到把结果交回来,中间经过哪些步骤?每一步为什么必须有?
  4. 生产环境里,这套机制会在哪些地方翻车?别人是怎么堵上的?

需要的前置知识:知道什么是 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
Star11,218
协议Apache-2.0(可商用、可改)
版本pyproject.tomlversion = "1.49.0"
体量src/ 下 52,049 行 Python
拉取时间2026-08-19

这个领域三个月一代,你看到时行号大概率已经变了。用文件名和函数名定位,不要用行号定位。

一、先看一个真实会翻车的场景

假设你让一个 Agent 干这么件事:

「把这个仓库的鉴权模块搞清楚,然后把登录超时的那个 bug 修了。」

它会怎么干?大概是这样:

  1. grep -r "auth" —— 返回 200 行文件路径
  2. auth/middleware.py —— 800 行代码进上下文
  3. auth/session.py —— 600 行进上下文
  4. config/settings.py —— 400 行进上下文
  5. 发现引用了 utils/token.py,再读 —— 300 行
  6. git log 看历史 —— 又是几百行
  7. ……找了十几个文件之后,终于明白了
  8. 开始改代码

问题出在第 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 里的几个名词对上号。不然打开源码会被 SoulRuntimeLaborMarket 这种名字搞晕。

一个生活化的类比

把整个系统想象成一个装修工程

代码里的名字类比大白话
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

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,不会报错,你不盯着就发现不了。

上限值定在哪?

src/kimi_cli/config.py(节选)
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_turn1000用户说一句话,最多允许它连续调 1000 次工具
max_retries_per_step3单步失败(网络抖动等)重试 3 次
compaction_trigger_ratio0.85上下文用到 85% 就开始自动压缩
reserved_context_size50,000永远给模型的输出留 5 万 token 空间

1000 这个数字说明了这个系统的定位:它假设的是「你说一句话,它自己跑几个小时」,不是聊天式的一问一答。作为对照,MiniMax 的 Mini-Agent 默认是 50 步,Manus 官方说他们平均一个任务约 50 次工具调用。

0.85 和 5 万这两个数字是配套的:不等塞满才压缩,因为压缩本身也要调模型、也要占空间。留出余量是为了「压缩这个动作本身别把上下文撑爆」。

到这一步为止,我们有了一个能干活的单 Agent。它就是第一节里那个会翻车的家伙。

Step 2:加一个「派活」的工具

现在给模型一个新工具,让它能把活外包出去。工具就叫 Agent

模型看到的工具长这样(参数定义在 src/kimi_cli/tools/agent/__init__.py):

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 实例分配一个磁盘目录

src/kimi_cli/subagents/store.py(节选)
@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 的生成方式也有讲究:

src/kimi_cli/tools/agent/__init__.py(节选)
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(只读探索工种)为例:

src/kimi_cli/agents/default/explore.yaml(节选)
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"

这几行在防什么:注意它做了两件事,不是一件。

  1. 提示词里说「你没有编辑文件的工具」
  2. 工具列表里真的把写文件的工具删掉了

为什么要做两遍? 这是个非常实用的经验:

  • 只做第 2 件(物理删掉工具):模型会困惑。它想改文件,发现没有这个工具,可能会转而用 Shell 执行 sed -i 绕过去——你以为堵住了,其实没有。
  • 只做第 1 件(提示词约束):模型大概率会听,但大概率不等于一定。任务一复杂,它就可能「为了完成任务」而违背约束。
  • 两件都做:模型知道自己是只读的(所以会用只读的方式思考问题),同时物理上也做不到写。

这条经验可以推广:凡是「绝对不能做」的事,提示词和代码要各堵一遍。

三个工种的权限对比:

工种Shell读文件写文件搜网派子 Agent适合干什么
coder真正改代码的活
explore✅ 仅只读快速摸清代码库
plan出方案,连命令都不让跑

还有一个关键设计:不同工种可以绑不同的模型。

src/kimi_cli/subagents/models.py(节选)
@dataclass(frozen=True, slots=True, kw_only=True)
class AgentTypeDefinition:
name: str
description: str
agent_file: Path
when_to_use: str = ""
default_model: str | None = None # ← 这一行
tool_policy: ToolPolicy = ...
supports_background: bool = True

default_model 让你可以给 explore 配一个便宜的小模型(它的活是搜索和阅读,不需要多强的推理),给 coder 配大模型。这是成本控制上最有效的一个杠杆,因为探索类的调用次数往往是改代码的好几倍。

Step 5:告诉模型什么时候该派活、什么时候不该

工具做好了,但模型不一定会用对。两种典型的用错:

  • 用少了:自己吭哧吭哧 grep 二十次,上下文还是爆了
  • 用多了:读一个已知路径的文件也要派个子 Agent,每次都是一整轮 LLM 会话,账单飞起

Kimi 的做法是在工具描述里给可执行的数字判据,而不是「适当时候使用」这种废话。工具描述在 src/kimi_cli/tools/agent/description.md

src/kimi_cli/tools/agent/description.md(节选)
**Explore Agent — Preferred for Codebase Research**

prefer `subagent_type="explore"` over doing the search yourself. Use it when:
- Your task will clearly require more than 3 search queries
- You need to understand how a module, feature, or code path works
- You want to investigate multiple independent questions — launch multiple
explore agents concurrently

**When Not To Use Agent**

- Reading a known file path
- Searching a small number of known files
- Tasks that can be completed in one or two direct tool calls

这几行在防什么:防止模型对「什么时候外包」这件事拿不定主意。

关键在**「超过 3 次搜索」**这个数字。这是一条模型能真正执行的规则——它可以在心里数「我要查的东西大概需要几次搜索」。换成「任务复杂时使用」,模型就只能靠感觉,行为会飘。

同样重要的是反面清单也写了。只写正面的话,模型会把什么都往子 Agent 上推。

还有一个细节:when_to_use 这段文字是从每个工种的 YAML 里读出来、动态拼进工具描述的:

src/kimi_cli/tools/agent/__init__.py(节选)
lines.append(
f"- `{name}`: {type_def.description} "
f"(Tools: {tool_names}, Model: {model}, Background: {background}).{suffix}"
)

也就是说,模型看到的工具描述里,会明确列出每个工种有哪些工具、用什么模型。这样它才能做出「这个活给 explore 还是给 coder」的判断,而不是瞎猜。

Step 6:结果怎么带回来(以及最容易翻车的地方)

子 Agent 干完了,怎么把结果交给主 Agent?

按照约定,只有最后一条消息会被带回去。于是出现了这个系统里最典型的一次翻车:

子 Agent 花了 20 分钟、读了 12 个文件、跑了 8 条命令,最后回了一句:

「已完成,我已经分析了鉴权模块。」

主 Agent 拿到这句话,等于什么都没拿到。而那 145K token 的探索过程已经随着子对话销毁了,找不回来了

Kimi 的解法很朴素——字数不够就打回去重写

src/kimi_cli/subagents/runner.py(节选)
SUMMARY_MIN_LENGTH = 200
SUMMARY_CONTINUATION_ATTEMPTS = 1
SUMMARY_CONTINUATION_PROMPT = """
Your previous response was too brief. Please provide a more comprehensive summary
that includes:

1. Specific technical details and implementations
2. Detailed findings and analysis
3. All important information that the parent agent should know
""".strip()

执行逻辑:

src/kimi_cli/subagents/runner.py(节选)
final_response = soul.context.history[-1].extract_text(sep="\n")
remaining = SUMMARY_CONTINUATION_ATTEMPTS
while remaining > 0 and len(final_response) < SUMMARY_MIN_LENGTH:
remaining -= 1
failure = await run_soul_checked(
soul, SUMMARY_CONTINUATION_PROMPT, ui_loop_fn, wire_path,
"continuing the agent summary",
)
...
final_response = soul.context.history[-1].extract_text(sep="\n")

画成流程图:

这几行在防什么:防止子 Agent 敷衍交差导致整段工作白做。

为什么只重写一次(SUMMARY_CONTINUATION_ATTEMPTS = 1:因为逼多了,模型会进入「硬凑字数」的状态——写一堆废话把长度撑到 200,反而更难读。逼一次能治住「随口一句」的情况,就够了。

这段代码几乎可以肯定是被线上问题逼出来的。 任何做过子 Agent 委派的人都撞过这堵墙。

Step 7:子 Agent 死了怎么办

子 Agent 会以各种方式失败:步数超限、API 报错、代码抛异常。这些异常绝对不能往上冒到主 Agent 的循环里——不然一个子任务失败会炸掉整个任务。

正确做法是:把异常翻译成一段主 Agent 看得懂的文字,当作工具返回值。

src/kimi_cli/subagents/runner.py(节选)
except MaxStepsReached as exc:
return SoulRunFailure(
message=(
f"Max steps {exc.n_steps} reached when {phase}. "
"Please try splitting the task into smaller subtasks."
),
brief="Max steps reached",
)
except RunCancelled:
raise
except asyncio.CancelledError:
raise
except APIStatusError as exc:
return SoulRunFailure(message=f"LLM API error (HTTP {exc.status_code}) ...", ...)
except Exception as exc:
logger.exception("Subagent soul run failed when {phase}", phase=phase)
return SoulRunFailure(message=f"Unexpected error when {phase}: {exc}", ...)

这几段在防什么,逐条说:

① 一般异常转成消息,不上抛。 子 Agent 炸了,主 Agent 收到的是「这个子任务失败了,因为 X」,它可以决定换个方式再试,或者告诉用户。整个任务不会中断。

② 取消类异常必须继续上抛RunCancelledCancelledError 那两个 raise)。用户按了 Ctrl-C,意思是「整个都别跑了」,这时候如果也翻译成「子任务失败了」,主 Agent 会以为只是子任务的问题,然后接着干——用户会以为按键没生效。

③ 错误消息里带行动建议。 注意那句 "Please try splitting the task into smaller subtasks."(请试着把任务拆得更小)。

第三点值得单独强调,因为它是 Agent 工程和普通后端工程的一个根本差别:

在 Agent 系统里,错误消息的读者是模型,不是人。

普通后端的报错是给运维看的,讲清楚「哪儿错了」就够。Agent 系统的报错会进入模型上下文,直接影响它下一步干什么。所以除了「哪儿错了」,还必须写「你接下来该怎么办」。

错误消息即提示词。 这条经验适用于你系统里所有会被模型读到的返回值。

Step 8:防止无限套娃

最后一个必须加的东西:子 Agent 能不能再派子 Agent?

如果不管,可能出现这种情况:主 Agent 派 3 个子 Agent,每个又派 3 个,每个再派 3 个……三层就是 27 个并发的 LLM 会话,账单和延迟同时爆炸。而且每一层都在正常工作,不会报错。

Kimi 的处理是三重保险

第三重的代码是 AgentTool.__call__ 的第一行:

src/kimi_cli/tools/agent/__init__.py(节选)
@override
async def __call__(self, params: Params) -> ToolReturnValue:
if self._runtime.role != "root":
return ToolError(
message="Subagents cannot launch other subagents.",
brief="Agent unavailable",
)

这一行在防什么:一行代码,把整棵委派树压成两层——主 Agent 和子 Agent,没有孙子 Agent。

为什么要做三重:单看每一重都够用了,做三重通常意味着两件事之一——要么线上真的出过事,要么他们非常清楚这件事出事的代价(钱)。

顺带一提:并不是所有产品都这么保守。Claude Code 默认允许嵌套 3 层,但也带了刹车(深度计数器、到顶自动撤走工具)。04 那篇 会展开。目前的行业共识大致是:默认单层,除非你能说清楚为什么需要更深。

五、把 spawn 的完整链路串一遍

前面八步是拆开讲的。现在把「派一个子 Agent」这个动作的完整链路串起来看。

Kimi 把这段公共逻辑抽成了一个函数 prepare_soul,六个步骤:

src/kimi_cli/subagents/core.py(节选)
async def prepare_soul(spec, runtime, builder, store, on_stage=None):
# 1. 按工种说明书造一个 Agent 实例
agent = await builder.build_builtin_instance(
agent_id=spec.agent_id, type_def=spec.type_def, launch_spec=spec.launch_spec,
)

# 2. 把这个实例的历史从磁盘读出来(新建的话就是空的)
context = Context(store.context_path(spec.agent_id))
await context.restore()

# 3. 系统提示词:续跑时用存盘的那份,首次运行时把当前这份存下来
if context.system_prompt is not None:
agent = replace(agent, system_prompt=context.system_prompt)
else:
await context.write_system_prompt(agent.system_prompt)

# 4. explore 工种且是新建时,在 prompt 前面塞一段 git 状态
prompt = spec.prompt
if spec.type_def.name == "explore" and not spec.resumed:
git_ctx = await collect_git_context(runtime.builtin_args.KIMI_WORK_DIR)
if git_ctx:
prompt = f"{git_ctx}\n\n{prompt}"

# 5. 把 prompt 存一份快照,纯为了排查问题
store.prompt_path(spec.agent_id).write_text(prompt, encoding="utf-8")

# 6. 让它上岗
soul = KimiSoul(agent, context=context)
return soul, prompt

其中第 3 步值得单独讲,它解决的问题不明显但很重要:

这几行在防什么:防止 CLI 升级之后,正在续跑的老会话行为突然改变。

想象这个场景:你昨天派了个子 Agent 在后台跑,今天 Kimi CLI 升级了,系统提示词改了一版。如果续跑时用新的提示词,这个子 Agent 的行为就会在中途突变——它前半段是按老规矩干的,后半段按新规矩,很容易产生自相矛盾的结果。

顺带还有个好处:前缀不变,KV-cache 就能继续命中。改提示词等于把缓存全废掉。

第 1 步展开看,里面还藏着一个重要决定:

src/kimi_cli/subagents/builder.py(节选)
async def build_builtin_instance(self, *, agent_id, type_def, launch_spec) -> Agent:
effective_model = self.resolve_effective_model(...)
llm_override = clone_llm_with_model_alias(...)
runtime = self._root_runtime.copy_for_subagent(
agent_id=agent_id, subagent_type=type_def.name, llm_override=llm_override,
)
return await load_agent(
type_def.agent_file,
runtime,
mcp_configs=[], # ← 这里
)

mcp_configs=[] 这一行在防什么:子 Agent 拿不到任何 MCP 工具。

这个决定的影响不小。你在主 Agent 里接了一堆 MCP server(数据库、Jira、内部 API),子 Agent 一个都用不了——「派个子 Agent 去查一下数据库」在 Kimi CLI 里做不到。

为什么要这么狠:MCP server 建连接有开销(子 Agent 是随用随建的,每次都连一遍很慢),而且每个 MCP server 的工具描述都要进上下文,接五个 server 可能就是上万 token——这对一个「本来就该轻量」的子 Agent 来说是浪费。

作为对照,Claude Code 是按子 Agent 粒度配 MCP 的,更灵活,但配错了就会重现 Kimi 想避免的这些问题。这是个明确的取舍,没有标准答案。

完整时序图:

六、从玩具到生产:还差三样东西

到这里,一个能用的子 Agent 委派机制就搭完了。但要放到生产上,Kimi 还多做了三件事。

6.1 超时

src/kimi_cli/tools/agent/__init__.py(节选)
MAX_FOREGROUND_TIMEOUT = 60 * 60  # 1 hour
MAX_BACKGROUND_TIMEOUT = 60 * 60 # 1 hour

在防什么:步数上限(1000 步)挡不住「单步卡住」的情况。一个 shell_exec 跑了个不会退出的命令,步数一直是 1,但时间无限。必须有墙钟时间的上限。

一小时这个值再次说明了系统定位:它准备好了让一个子任务跑很久。

6.2 后台执行

前面画的都是「主 Agent 派活 → 等着 → 拿结果」,这叫前台。但有些活不用等,比如「顺便把测试跑一遍」。

src/kimi_cli/tools/agent/__init__.py(节选)
run_in_background: bool = Field(
default=False,
description=(
"Whether to run the agent in the background. Prefer false unless the task can "
"continue independently and there is a clear benefit to returning control before "
"the result is needed."
),
)

注意默认值是 False,而且描述里写了「除非……否则优先用 false」。 后台听起来更高效,但它引入了并发问题(两个子 Agent 同时改一个文件),所以默认关掉、需要时才开,是更稳的选择。

后台启动成功后,返回给模型的不是 JSON,是一段结构化文本:

src/kimi_cli/tools/agent/__init__.py(节选)
lines = [
f"task_id: {view.spec.id}",
f"status: {view.runtime.status}",
f"agent_id: {agent_id}",
"automatic_notification: true",
"next_step: You will be automatically notified when it completes.",
(
"next_step: Use TaskOutput with this task_id for a non-blocking status/output "
"snapshot. Only set block=true when you intentionally want to wait."
),
f'resume_hint: Use Agent(resume="{agent_id}", prompt="...") to continue this '
"instance later.",
]

这几行在防什么:防止模型不知道「派了个后台任务之后我该干嘛」。

next_step:resume_hint: 这些字段名不是给程序解析的,是给模型读的。它把「接下来能做什么」直接写进工具返回值,好处是这段说明只在真的派了后台任务之后才出现在上下文里——不占常驻的系统提示词空间。

这又是那条经验的延伸:所有模型会读到的返回值,都是提示词的一部分。

6.3 续跑:子 Agent 是有身份的

最后一个,也是 Kimi 比较少见的一个设计:子 Agent 实例可以被「复活」。

src/kimi_cli/tools/agent/__init__.py(节选)
resume: str | None = Field(
default=None,
description="Optional agent ID to resume instead of creating a new instance.",
)

传一个 agent_id 进来,就接着那个实例之前的历史继续干,而不是新建一个。

为什么有用:假设主 Agent 派 explore 查了鉴权模块,拿到结论后改代码,改到一半发现「诶,那个 refresh_token 到底存哪了?」——如果新建一个子 Agent,它得从头再翻一遍那 12 个文件。用 resume,那个子 Agent 的历史还在,一问就答。

这意味着子 Agent 不再是「无状态的函数调用」,而是有身份、有记忆的长期实体

有了状态,就必须有状态机:

src/kimi_cli/subagents/models.py(节选)
type SubagentStatus = Literal[
"idle",
"running_foreground",
"running_background",
"completed",
"failed",
"killed",
]

有了状态机,就有了并发问题。这段代码和它的注释非常值得看:

src/kimi_cli/tools/agent/__init__.py(节选)
# Mark running_background synchronously before dispatching the
# async task so that concurrent resume attempts see the guard
# immediately (asyncio.create_task only queues the coroutine).
self._runtime.subagent_store.update_instance(
agent_id,
status="running_background",
)

这几行在防什么:防止同一个子 Agent 被同时 resume 两次。

具体是怎么出问题的asyncio.create_task 只是把协程排进队列,它并不立刻执行。如果状态是在协程内部才改的,那么两次紧挨着的 resume 调用会看到状态还是 completed,于是都通过了检查,同一个 context.jsonl 被两个协程同时写——历史就乱了。

解法是在派发异步任务之前,同步地把状态改掉。这是并发编程里的经典坑,这段注释写得比大部分开源项目都清楚。

配套的检查和回滚:

src/kimi_cli/tools/agent/__init__.py(节选)
if params.resume:
record = self._runtime.subagent_store.require_instance(params.resume)
if record.status in {"running_foreground", "running_background"}:
return ToolError(
message=(
f"Agent instance {record.agent_id} is still {record.status} and cannot "
"be resumed concurrently."
),
brief="Agent already running",
)
src/kimi_cli/tools/agent/__init__.py(节选)
except Exception:
self._runtime.subagent_store.update_instance(agent_id, status="idle")
if created_instance:
self._runtime.subagent_store.delete_instance(agent_id)
raise

后面这段在防什么:防止「状态已经改成 running 了,但任务派发失败」——那样这个实例会永远卡在 running,再也 resume 不了。所以出错要把状态改回去,新建的实例还要删掉。

七、会在哪儿翻车:症状 → 原因 → 对策

把前面所有「在防什么」汇总成一张排查表。你自己实现时可以直接对照:

你观察到的症状真正的原因对应的防护
子 Agent 干了半天,回一句「已完成」只有最后一条消息会回传,模型不知道要写详细摘要设字数下限,不够就打回重写一次(Step 6)
账单莫名其妙翻了十几倍子 Agent 套子 Agent,指数级展开三重保险禁止递归(Step 8)
说好只读,结果代码被改了光靠提示词约束不住模型提示词 + 工具剥离双保险(Step 4)
升级之后,老的续跑任务行为变了续跑时用了新版系统提示词续跑复用存盘的提示词(第五节第 3 步)
同一个子 Agent 的历史文件乱了并发 resume,两个协程同时写派发前同步改状态(6.3)
子 Agent 加载特别慢 / 上下文一上来就很满MCP server 的连接开销和工具描述mcp_configs=[],子 Agent 不给 MCP(第五节)
模型撞到步数上限后反复重试同样的事错误消息只说了「错了」,没说「该怎么办」错误消息里附行动建议(Step 7)
任务一直在跑,步数却没涨单步卡死,步数上限拦不住墙钟超时,上限 1 小时(6.1)
用户按了 Ctrl-C 但任务还在跑取消异常被当成普通失败吞掉了取消类异常必须 raise 上抛(Step 7)
出错后这个子 Agent 再也启动不了状态卡在 running,没回滚异常时把状态改回 idle 并删除新建实例(6.3)

这张表比任何架构图都能说明「生产级」三个字的含义。 上面每一行,都是有人在真实环境里踩过之后才写进代码的。

八、全局定位:Kimi CLI 在这个坐标系的哪儿

把整套机制画成一张总图:

01 那篇的五个维度 打分:

维度Kimi CLI 的答案一句话解释
D1 隔离单位一个 context.jsonl 文件 + 独立工具白名单同进程内的协程,隔离靠文件系统而非操作系统
D2 通信拓扑严格星型子 Agent 之间零通信,全部经主 Agent 中转
D3 结果回收只回传最后一条消息,低于 200 字强制重写一次治的是「太短」这一头
D4 递归深度单层,三重保险硬禁止没有孙子 Agent
D5 生命周期前台 / 后台 / 可 resume 复活,单次最长 1 小时子 Agent 是有身份的实体,不是一次性函数

一句话总结:Kimi CLI 的多 Agent 是保守的星型单层结构。它没有在拓扑上玩花样(没有群聊、没有兄弟通信、没有递归),而是把全部工程力气花在了两件事上——隔离要彻底结论要可用

九、小结与自查清单

如果你要自己实现一套子 Agent 委派,照着下面这张表走一遍:

基础机制

  • 子 Agent 有自己独立的对话历史吗?是物理隔离(不同文件/进程)还是只靠约定?
  • 主 Agent 能拿到子 Agent 的什么?只有最后一条消息,还是全部过程?这件事告诉模型了吗
  • 派活的任务描述是自包含的吗?子 Agent 看不到主对话,缺的信息补上了吗?

权限与成本

  • 不同工种的工具权限分开了吗?只读工种是提示词和代码各堵了一遍,还是只堵了一遍?
  • 便宜的活(搜索、阅读)能不能绑个便宜的模型?
  • 子 Agent 要不要给 MCP?想清楚连接开销和工具描述的上下文占用了吗?

防炸

  • 步数上限有吗?墙钟超时有吗?(两个都要,挡的不是同一种卡死)
  • 子 Agent 能不能再派子 Agent?如果不能,是在几层堵的?
  • 子 Agent 的异常会不会炸掉主流程?取消类异常有没有被误吞?

结果质量

  • 摘要有没有下限?不够会怎么办?
  • 摘要有没有上限?太长会不会撑爆主上下文?(Kimi 只治了下限,DeerFlow 治的是上限,理想是两头都治)

给模型的信息

  • 「什么时候该派活」有没有给一个可执行的数字判据,而不是「适当时候」?
  • 「什么时候不该派活」的反面清单写了吗?
  • 报错消息里除了「哪儿错了」,有没有写「你接下来该怎么办」?
  • 给模型看的 ID 够短吗?(它要能准确抄回来)

可排查

  • 每个子 Agent 启动时的 prompt 有没有存快照?
  • 状态机的状态变更是同步写的吗?失败会回滚吗?

十、参考