作者归档:admin

AI Agent 核心策略:如何判断 Agent 应该停止

简单来讲,AI Agent 实现的的大逻辑就是一个大的循环 + 获取上下文 + 不停的 LLM 调用 + 工具的调用。

那么一个关键问题就出现了:这个循环什么时候应该停止?如果处理不当,Agent 可能会陷入无限循环,浪费计算资源,或者过早停止而无法完成任务。本文将深入探讨 AI Agent 停止策略的核心设计思路。

常用停止策略

AI Agent 停止策略无外乎以下几种情况:

1. 硬性限制

最简单粗暴的方法:

  • 最大步数限制(比如最多循环 30 次)
  • 执行时间限制(比如最多跑 5 分钟)
  • API 调用次数限制(比如最多调 100 次)
  • API 调用 Token 数限制

这种方法简单有效,但用户体验很差。经常出现任务做到一半被强制停止的情况。

2. 任务完成检测

让 LLM 判断任务是否完成:

# 每次循环后问 LLM
response = llm.ask("任务是否已经完成?")
if response == "是":
    stop()

3. 显式停止信号

给 Agent 一个专门的”停止”工具:

tools = [
    "search",
    "calculate", 
    "terminate"  # 专门用来停止
]

当 Agent 调用 terminate 工具时就停止。这个方法不错,但需要在 prompt 里教会 Agent 什么时候该调用它。

4. 循环检测

检测 Agent 是否在做重复的事:

  • 连续多次调用同一个工具
  • 动作序列出现循环模式(A→B→A→B…)
  • 输出内容高度相似

5. 错误累积

连续失败多次就放弃:

if consecutive_errors > 3:
    stop("连续失败太多次")

6. 用户中断

让用户能随时喊停。

下面我们以 OpenManus 和 Gemini CLI 的源码来看一下他们是怎么做的。

OpenManus 的停止逻辑

OpenManus 的停止机制设计得比较完整,它用了一个多层防护的思路。

核心:terminate 工具

OpenManus 给每个 Agent 都配了一个 terminate 工具:

class Terminate(BaseTool):
    name: str = "terminate"
    description = """当请求已满足或无法继续时终止交互。
    完成所有任务后,调用此工具结束工作。"""
    
    async def execute(self, status: str) -> str:
        return f"交互已完成,状态:{status}"
        
以上为示例,原始代码:
from app.tool.base import BaseTool


_TERMINATE_DESCRIPTION = """Terminate the interaction when the request is met OR if the assistant cannot proceed further with the task.
When you have finished all the tasks, call this tool to end the work."""


class Terminate(BaseTool):
    name: str = "terminate"
    description: str = _TERMINATE_DESCRIPTION
    parameters: dict = {
        "type""object",
        "properties": {
            "status": {
                "type""string",
                "description""The finish status of the interaction.",
                "enum": ["success""failure"],
            }
        },
        "required": ["status"],
    }

    async def execute(self, status: str) -> str:
        """Finish the current execution"""
        return f"The interaction has been completed with status: {status}"

OpenManus 使用的是方案 3,把「何时停止」的决策权交给了 LLM。prompt 里会明确告诉 Agent:任务完成了就调用 terminate。

状态机管理

OpenManus 用状态机来管理 Agent 的生命周期:

class AgentState(Enum):
    IDLE = "idle"
    RUNNING = "running"  
    FINISHED = "finished"
    ERROR = "error"

当检测到特殊工具(如 terminate)被调用时,会触发状态转换:

async def _handle_special_tool(self, name: str, result: Any):
    if name.lower() == "terminate":
        self.state = AgentState.FINISHED
        logger.info(" 任务完成!")

步数限制

不同类型的 Agent 有不同的步数上限:

# ToolCallAgent: 30 步
# SWEAgent: 20 步
# PlanningFlow: 可配置

while self.current_step < self.max_steps and self.state != AgentState.FINISHED:
    self.current_step += 1
    await self.step()

if self.current_step >= self.max_steps:
    results.append(f"达到最大步数限制 ({self.max_steps})")

这是一个保底机制,防止 Agent 无限运行。

卡死检测

OpenManus 还会检测 Agent 是否卡住了:

def is_stuck(self) -> bool:
    # 检查是否有重复的 assistant 消息
    # 如果最近的回复都一样,说明卡住了
    recent_messages = self.get_recent_assistant_messages()
    if len(set(recent_messages)) == 1:
        return True
    return False

Planning Agent 的结束逻辑

1. 计划完成的判断机制

PlanningFlow 的结束判断并不是简单检查所有步骤是否完成:

# 在主执行循环中
while True:
    # 获取当前需要执行的步骤
    self.current_step_index, step_info = await self._get_current_step_info()
    
    # 如果没有更多活跃步骤,则结束计划
    if self.current_step_index is None:
        result += await self._finalize_plan()
        break

2. 步骤状态检查逻辑

_get_current_step_info() 方法负责判断是否还有未完成的步骤:

# 查找第一个非完成状态的步骤
for i, step in enumerate(steps):
    if i >= len(step_statuses):
        status = PlanStepStatus.NOT_STARTED.value
    else:
        status = step_statuses[i]
    
    # 如果步骤状态为活跃状态(未开始或进行中),返回该步骤
    if status in PlanStepStatus.get_active_statuses():
        return i, step_info

# 如果没找到活跃步骤,返回 None
return None, None

其中 get_active_statuses() 返回 ["not_started", "in_progress"],意味着只有当所有步骤都是 "completed""blocked" 状态时,计划才会结束。

3. 计划结束处理

当没有更多活跃步骤时,会调用 _finalize_plan() 方法:

async def _finalize_plan(self) -> str:
    """使用 LLM 生成计划完成总结"""
    plan_text = await self._get_plan_text()
    
    # 使用 LLM 生成总结
    system_message = Message.system_message(
        "You are a planning assistant. Your task is to summarize the completed plan."
    )
    
    user_message = Message.user_message(
        f"The plan has been completed. Here is the final plan status:\n\n{plan_text}\n\nPlease provide a summary of what was accomplished and any final thoughts."
    )
    
    response = await self.llm.ask(messages=[user_message], system_msgs=[system_message])
    return f"Plan completed:\n\n{response}"

Gemini CLI 的停止逻辑

Gemini CLI 的设计思路完全不同,它用了一个更优雅但也更复杂的方案。

subagent 的停止逻辑

1. 达到最大轮次(MAX_TURNS)

if (this.runConfig.max_turns && turnCounter >= this.runConfig.max_turns) {
    this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS;
    break;
}

这是最简单的保护机制,防止无限循环。

2. 执行超时(TIMEOUT)

let durationMin = (Date.now() - startTime) / (1000 * 60);
if (durationMin >= this.runConfig.max_time_minutes) {
    this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
    break;
}

注意这里检查了两次超时:

  • 在调用 LLM 之前检查一次
  • 在调用 LLM 之后又检查一次

这是因为 LLM 调用可能很耗时,要确保不会超时太多。

3. 用户中断(通过 AbortSignal)

if (abortController.signal.aborted) return;

这个检查出现在 stream 处理循环里,确保能及时响应用户的取消操作。

4. 错误异常(ERROR)

catch (error) {
    console.error('Error during subagent execution:', error);
    this.output.terminate_reason = SubagentTerminateMode.ERROR;
    throw error;
}

任何未捕获的异常都会导致停止。

5. 目标完成(GOAL)

目标完成的判断分两种情况:

情况A:没有预定输出要求

if (!this.outputConfig || Object.keys(this.outputConfig.outputs).length === 0) {
    // 没有要求特定输出,LLM 不调用工具就认为完成了
    if (functionCalls.length === 0) {
        this.output.terminate_reason = SubagentTerminateMode.GOAL;
        break;
    }
}

情况B:有预定输出要求

// 检查是否所有要求的变量都已输出
const remainingVars = Object.keys(this.outputConfig.outputs).filter(
    (key) => !(key in this.output.emitted_vars)
);

if (remainingVars.length === 0) {
    this.output.terminate_reason = SubagentTerminateMode.GOAL;
    break;
}

声明式输出系统的实现

声明式输出系统的核心是 outputConfig

// 预先声明需要什么输出
this.outputConfig = {
    outputs: {
        "summary""string",
        "recommendations""array", 
        "risk_score""number"
    }
};

// Agent 通过 self.emitvalue 工具来产生输出
// 每次调用会把值存到 this.output.emitted_vars 里
this.output.emitted_vars = {
    "summary""这是总结...",
    "recommendations": ["建议1""建议2"]
    // risk_score 还没输出
};

系统会不断检查 emitted_vars 是否包含了所有 outputs 中声明的变量。只有全部输出了才认为目标完成。

Nudge 机制

Nudge(轻推)机制代码:

if (functionCalls.length === 0) {  // LLM 停止调用工具了
    // 检查是否还有变量没输出
    const remainingVars = Object.keys(this.outputConfig.outputs).filter(
        (key) => !(key in this.output.emitted_vars)
    );
    
    if (remainingVars.length > 0) {
        // 还有变量没输出,"推"它一下
        const nudgeMessage = `You have stopped calling tools but have not emitted 
        the following required variables: ${remainingVars.join(', ')}. 
        Please use the 'self.emitvalue' tool to emit them now, 
        or continue working if necessary.`;
        
        // 把提醒作为新的用户消息发给 LLM
        currentMessages = [{
            role: 'user',
            parts: [{ text: nudgeMessage }]
        }];
        
        // 继续循环,不退出
    }
}

完整的 subagent 执行流程

开始
  ↓
while (true) {
  检查是否超时/超轮次 → 是 → 退出
    ↓ 否
  调用 LLM
    ↓
  LLM 返回工具调用?
    ├─ 是 → 执行工具 → 检查目标是否完成
    │         ├─ 是 → 退出
    │         └─ 否 → 继续循环
    │
    └─ 否(LLM 停止调用工具)
         ↓
       有预定输出要求吗?
         ├─ 没有 → 退出(认为完成)
         └─ 有 → 检查是否都输出了
                   ├─ 是 → 退出
                   └─ 否 → Nudge 提醒 → 继续循环
}

三层循环检测机制

第一层:工具调用重复检测

这是最简单直接的检测,针对 Agent 反复调用相同工具的情况。

private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
    // 把工具名和参数一起哈希,生成唯一标识
    const key = this.getToolCallKey(toolCall);
    
    if (this.lastToolCallKey === key) {
        // 和上次调用完全一样,计数+1
        this.toolCallRepetitionCount++;
    } else {
        // 不一样,重置计数
        this.lastToolCallKey = key;
        this.toolCallRepetitionCount = 1;
    }
    
    // 连续5次调用相同工具+相同参数 = 循环
    if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
        return true;
    }
}

触发条件:连续 5 次调用完全相同的工具(包括参数)。

这种检测很严格——必须是连续的、完全相同的调用。如果中间插入了其他工具调用,计数就会重置。

第二层:内容重复检测(”咒语”检测)

这是最复杂的部分,用来检测 LLM 输出重复内容的情况,就像在念咒语一样。

private checkContentLoop(content: string): boolean {
    // 1. 先检查是否在特殊内容块中(代码块、表格、列表等)
    const numFences = (content.match(/```/g) ?? []).length;
    const hasTable = /(^|\n)\s*(\|.*\||[|+-]{3,})/.test(content);
    // ... 检查各种格式
    
    // 在代码块中不检测循环(代码本来就可能有重复)
    if (this.inCodeBlock) {
        return false;
    }
    
    // 2. 把新内容加入历史
    this.streamContentHistory += content;
    
    // 3. 保持历史在 1000 字符以内
    this.truncateAndUpdate();
    
    // 4. 分析内容块是否重复
    return this.analyzeContentChunksForLoop();
}

核心算法是滑动窗口 + 哈希检测

private analyzeContentChunksForLoop(): boolean {
    while (this.hasMoreChunksToProcess()) {
        // 提取 50 字符的块
        const currentChunk = this.streamContentHistory.substring(
            this.lastContentIndex,
            this.lastContentIndex + CONTENT_CHUNK_SIZE  // 50
        );
        
        // 计算哈希
        const chunkHash = createHash('sha256').update(currentChunk).digest('hex');
        
        // 检查这个块是否重复出现
        if (this.isLoopDetectedForChunk(currentChunk, chunkHash)) {
            return true;
        }
        
        // 滑动窗口向前移动 1 个字符
        this.lastContentIndex++;
    }
}

判断循环的条件:

private isLoopDetectedForChunk(chunk: string, hash: string): boolean {
    const existingIndices = this.contentStats.get(hash);
    
    if (!existingIndices) {
        // 第一次见到这个块,记录位置
        this.contentStats.set(hash, [this.lastContentIndex]);
        return false;
    }
    
    // 验证内容确实相同(防止哈希碰撞)
    if (!this.isActualContentMatch(chunk, existingIndices[0])) {
        return false;
    }
    
    existingIndices.push(this.lastContentIndex);
    
    // 需要出现至少 10 次
    if (existingIndices.length < CONTENT_LOOP_THRESHOLD) {  // 10
        return false;
    }
    
    // 关键:这 10 次必须距离很近(平均距离 ≤ 75 字符)
    const recentIndices = existingIndices.slice(-CONTENT_LOOP_THRESHOLD);
    const totalDistance = recentIndices[recentIndices.length - 1] - recentIndices[0];
    const averageDistance = totalDistance / (CONTENT_LOOP_THRESHOLD - 1);
    const maxAllowedDistance = CONTENT_CHUNK_SIZE * 1.5;  // 75
    
    return averageDistance <= maxAllowedDistance;
}

触发条件:同一个 50 字符的内容块,在很短的距离内重复出现 10 次

第三层:LLM 智能检测

这是最高级的检测,用 AI 来判断 AI 是否陷入循环。

private async checkForLoopWithLLM(signal: AbortSignal) {
    // 取最近 20 轮对话
    const recentHistory = this.config
        .getGeminiClient()
        .getHistory()
        .slice(-LLM_LOOP_CHECK_HISTORY_COUNT);  // 20
    
    // 清理历史(去掉悬空的函数调用等)
    const trimmedHistory = this.trimRecentHistory(recentHistory);
    
    // 让 Gemini Flash 模型分析
    const result = await this.config.getBaseLlmClient().generateJson({
        contents: [...trimmedHistory, { role: 'user', parts: [{ text: taskPrompt }] }],
        schema: {
            type'object',
            properties: {
                reasoning: { type'string' },
                confidence: { type'number' }  // 0-1 之间
            }
        },
        model: DEFAULT_GEMINI_FLASH_MODEL,
        systemInstruction: LOOP_DETECTION_SYSTEM_PROMPT
    });
    
    if (result['confidence'] > 0.9) {
        // 高置信度认为是循环
        console.warn(result['reasoning']);
        return true;
    }
}

触发时机:

async turnStarted(signal: AbortSignal) {
    this.turnsInCurrentPrompt++;
    
    if (
        this.turnsInCurrentPrompt >= LLM_CHECK_AFTER_TURNS &&  // 至少 30 轮
        this.turnsInCurrentPrompt - this.lastCheckTurn >= this.llmCheckInterval
    ) {
        this.lastCheckTurn = this.turnsInCurrentPrompt;
        return await this.checkForLoopWithLLM(signal);
    }
}
  • 必须执行超过 30 轮才开始检查(避免误判)
  • 不是每轮都检查,有间隔(默认 3 轮)
  • 间隔会根据置信度动态调整(5-15 轮)
// 动态调整检查频率
this.llmCheckInterval = Math.round(
    MIN_LLM_CHECK_INTERVAL +  // 5
    (MAX_LLM_CHECK_INTERVAL - MIN_LLM_CHECK_INTERVAL) * (1 - result['confidence'])
    // 置信度越高,检查越频繁
);

三种循环类型

系统定义了三种循环类型:

enum LoopType {
    CONSECUTIVE_IDENTICAL_TOOL_CALLS,  // 连续相同工具调用
    CHANTING_IDENTICAL_SENTENCES,      // 重复输出相同内容
    LLM_DETECTED_LOOP                  // LLM 检测到的逻辑循环
}

每种都有不同的检测方法和触发条件。

这比较适合处理长对话场景,既能有效检测循环,又不会因为过于敏感而误判正常的迭代操作。

小结

AI Agent 的停止策略是一个容易被忽视但极其重要的技术问题。从原理上看,Agent 就是一个大循环,不断调用 LLM 和工具来完成任务,但如果没有合理的停止机制,就会出现无限循环浪费资源,或者过早停止无法完成任务的问题。常见的停止方案包括硬性限制(步数、时间、API调用次数)、任务完成检测、显式停止信号、循环检测、错误累积和用户中断等,实际应用中需要组合使用多种策略。

OpenManus 采用了相对简单直接的设计:给每个 Agent 配备 terminate 工具,让 LLM 自己决定何时停止,同时用状态机管理生命周期,配合步数限制作为保底,并确保无论如何停止都会正确清理资源。

而 Gemini CLI 的设计更加精巧,核心是声明式输出系统——预先定义需要什么输出,只有全部输出才算完成,如果 Agent 停止调用工具但还有变量未输出,系统会通过 Nudge 机制温和提醒;在循环检测上,Gemini 实现了三层防护:工具调用重复检测(连续5次相同调用)、内容重复检测(滑动窗口+哈希算法检测”咒语”现象)、以及用 LLM 分析对话历史判断是否陷入逻辑循环。

实践中的关键是不要依赖单一停止机制,要组合使用多种策略形成多层防护,给 LLM 明确的停止指引,为不同类型的停止原因提供清晰的用户反馈,并确保资源能够可靠清理。停止策略的本质是在”让 Agent 完成任务”和”防止失控”之间找到平衡点。

以上。

AI Agent 核心管理逻辑:工具的管理和调度

简单来说,AI Agent 就是一个能够自主感知环境、制定计划、采取行动来完成特定目标的系统。

从架构的角度来看,又可以分为感知模块(包括状态上下文和意图上下文,而意图上下文是一个更难一些的问题),推理模块(LLM 主控部分),记忆模块,行动模块。

今天我们要聊的是贯穿于各个模块中的工具,工具不仅仅是行动模块,在感知,记忆中都有可能用到工具。

1. OpenManus 的工具管理和调度

看了 OpenManus 的代码,整个工程中 Tool 的占比还是比较大的,这也不是一个大而全的框架。

直接看代码

1.1 BaseTool 基础类

class BaseTool(ABC):
    name: str
    description: str  
    parameters: Dict[str, Any]
    
    @abstractmethod
    async def execute(self, tool_input: Dict[str, Any]) -> ToolResult

name 用于标识,description 给 LLM 看,parameters 定义输入规范,execute 执行具体逻辑。这几个字段,已经涵盖了工具管理的核心要素。

1.2 ToolCollection 管理器

工具集合的管理通过 ToolCollection 类实现,核心是一个字典:

class ToolCollection:
    def __init__(self):
        self.tool_map: Dict[str, BaseTool] = {}

整个实现有以下三个小点:

  • O(1) 的查找性能。用工具名作为 key,直接查找,没有遍历,没有复杂的匹配逻辑。
  • 统一的执行接口。所有工具调用都通过 execute 方法,传入工具名和参数,返回统一的 ToolResult。这种一致性让上层调用变得极其简单。
  • 统一的错误处理。工具不存在、执行失败都会返回 ToolFailure,不会抛异常打断流程。这种设计让系统更健壮。
def add_tool(self, tool: BaseTool) -> None:
    if tool.name in self.tool_map:
        logger.warning(f"Tool {tool.name} already exists, overwriting")
    self.tool_map[tool.name] = tool

在添加工具时可以覆盖已有工具。

1.3 Think-Act 循环

OpenManus 实现了标准的 ReAct(Reasoning and Acting)模式,但做了很多细节优化:

async def step(self) -> None:
    await self.think()  # 思考:决定用什么工具
    await self.act()    # 行动:执行工具调用

这个实现是在 ReActAgent 中定义的,think 阶段让 LLM 分析当前状态并选择工具,act 阶段执行工具并收集结果。两个阶段分离,让整个流程可控、可调试。

1.4 工具选择

工具选择是整个系统的核心:

async def think(self) -> None:
    response = await self.llm.ask_tool(
        messages=self.memory.get_messages(),
        system_prompts=self.system_prompts,
        available_tools=self.available_tools.get_tool_schemas(),
        tool_choices=self.tool_choices
    )

它把几个关键信息都传给 LLM:

  • 历史对话(messages):让 LLM 了解上下文
  • 系统提示(system_prompts):定义 Agent 的行为规范
  • 可用工具(available_tools):告诉 LLM 有哪些工具可用
  • 选择模式(tool_choices):控制是否必须选择工具

这种设计让工具选择既灵活又可控。

1.5 批量执行和结果处理

OpenManus 支持一次选择多个工具并行执行:

async def act(self) -> None:
    if self.tool_calls:
        for tool_call in self.tool_calls:
            observation = await self.execute_tool(tool_call)
            self.memory.add_message(ToolMessage(observation, tool_call.id))

每个工具的执行结果都会记录到 memory 中,供下一轮思考使用。

1.6 特殊工具处理

OpenManus 预留了特殊工具的处理机制:

def handle_special_tool(self, tool_call: ToolCall) -> str:
    if tool_call.name == "Terminate":
        self.should_stop = True
        return "Task completed successfully."
    return f"Unknown special tool: {tool_call.name}"

Terminate 工具用于优雅终止,这种设计避免了硬编码的退出逻辑。你可以轻松添加其他特殊工具,比如 Pause(暂停)、Checkpoint(保存状态)等。

2. Gemini CLI 的工具管理和调度

Gemini CLI 把所有工具都扔给大模型,让它自己选

// 就这么简单,所有工具一股脑给 LLM
async setTools(): Promise<void> {
  const toolRegistry = this.config.getToolRegistry();
  const toolDeclarations = toolRegistry.getFunctionDeclarations();
  const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
  this.getChat().setTools(tools);
}

现在的大模型已经足够聪明,能够根据上下文选择合适的工具。与其花大力气设计复杂的工具选择策略,不如相信 AI 的判断力。但会有可能存在工具「爆炸」的情况,这个后面我们再聊。

2.1 三层工具发现机制

Gemini CLI 在工具发现方面设计了三层机制:

1. 内置核心工具

这些是精心挑选的常用工具,覆盖了大部分日常开发需求:

  • 文件操作:ls、read-file、write-file、edit
  • 代码搜索:grep、ripgrep、glob
  • 系统交互:shell
  • 网络请求:web-fetch、web-search
  • 记忆管理:memory

每个工具都经过精心设计,接口清晰,功能专一。比如 edit 工具,不仅能编辑文件,还支持预览修改。

2. 命令行工具发现

这是个很巧妙的设计。我们可以通过配置一个发现命令,让系统自动发现和注册新工具:

// 执行发现命令,解析返回的工具声明
async discoverAndRegisterToolsFromCommand(): Promise<void> {
  const command = this.config.getConfigOptions().toolDiscoveryCommand;
  if (!command) return;
  
  const result = await exec(command);
  const functions = JSON.parse(result.stdout);
  
  for (const func of functions) {
    this.registerTool(new DiscoveredTool(func));
  }
}

这意味着我们可以轻松扩展工具集,只要我们的工具能输出符合格式的 JSON 声明即可。

3. MCP 服务器集成

MCP(Model Context Protocol)是个通用的协议,Gemini CLI 对它的支持相当完善:

// 支持多种传输协议
- stdio:标准输入输出
- SSE:服务器推送事件
- HTTP:REST API
- WebSocket:双向通信

通过 MCP,我们可以接入各种专业工具服务器,比如数据库查询、API 调用、专业领域工具等。每个 MCP 服务器的工具都是隔离的,避免了命名冲突。

2.2 工具调度器

Gemini CLI 的工具调度器采用了清晰的状态机模型:

// 工具执行状态流转
validating → scheduled → awaiting_approval → executing → success/error/cancelled

在执行前先验证参数,避免无效调用:

export type ValidatingToolCall = {
  status: 'validating';
  request: ToolCallRequestInfo;
  tool: AnyDeclarativeTool;
  invocation: AnyToolInvocation;
};

根据工具风险等级,提供不同的确认策略:

// 三种确认模式
ApprovalMode.DEFAULT    // 标准确认
ApprovalMode.AUTO_EDIT  // 自动编辑确认
ApprovalMode.YOLO       // 无确认模式(勇者模式)

YOLO 模式的命名有点意思,”You Only Live Once人生苦短,活出精彩

独立的工具调用可以并行执行,提高效率:

// 并行处理多个工具调用
for (const fnCall of functionCalls) {
  // 不等待,直接调度下一个
  this.handlePendingFunctionCall(fnCall);
}

工具执行过程中的输出会实时展示,用户体验很好:

// 实时更新输出流
onUpdate?: (output: string) => void;

2.3 错误处理机制

1. 错误类型分类

系统定义了完整的错误类型枚举 ToolErrorType,包括:

通用错误:

  • INVALID_TOOL_PARAMS – 工具参数无效
  • UNKNOWN – 未知错误
  • UNHANDLED_EXCEPTION – 未处理的异常
  • TOOL_NOT_REGISTERED – 工具未注册
  • EXECUTION_FAILED – 执行失败

文件系统错误:

  • FILE_NOT_FOUND – 文件未找到
  • FILE_WRITE_FAILURE – 文件写入失败
  • PERMISSION_DENIED – 权限拒绝
  • PATH_NOT_IN_WORKSPACE – 路径不在工作空间内

Shell 特定错误:

  • SHELL_EXECUTE_ERROR – Shell 执行错误

2. 工具调用状态管理

系统定义了完整的工具调用状态类型:

export type ToolCall =
  | ValidatingToolCall      // 验证中
  | ScheduledToolCall       // 已调度
  | ErroredToolCall         // 错误
  | SuccessfulToolCall      // 成功
  | ExecutingToolCall       // 执行中
  | CancelledToolCall       // 已取消
  | WaitingToolCall;        // 等待批准

3. 错误处理流程

验证阶段错误处理

_schedule 方法中,系统会:

  1. 工具注册检查:验证工具是否已注册
  2. 参数验证:使用 tool.build(args) 验证参数
  3. 异常捕获:捕获验证过程中的所有异常
try {
  const invocationOrError = this.buildInvocation(toolInstance, args);
  if (invocationOrError instanceof Error) {
    return {
      status: 'error',
      request: reqInfo,
      response: createErrorResponse(
        reqInfo,
        invocationOrError,
        ToolErrorType.INVALID_TOOL_PARAMS,
      ),
      durationMs: 0,
    };
  }
} catch (error) {
  // 处理验证异常
}

执行阶段错误处理

attemptExecutionOfScheduledCalls 方法中,系统会:

  1. 执行异常捕获:使用 Promise .catch() 捕获执行异常
  2. 错误响应生成:调用 createErrorResponse 生成标准化错误响应
  3. 状态更新:将工具调用状态设置为 ‘error’
promise
  .then(async (toolResult: ToolResult) => {
    // 处理成功结果
  })
  .catch((error: Error) => {
    this.setStatusInternal(
      callId,
      'error',
      createErrorResponse(reqInfo, error, ToolErrorType.UNHANDLED_EXCEPTION),
    );
  });

4. 错误响应格式

createErrorResponse 函数生成标准化的错误响应:

const createErrorResponse = (
  request: ToolCallRequestInfo,
  error: Error,
  errorType: ToolErrorType | undefined,
): ToolCallResponseInfo => ({
  callId: request.callId,
  error,
  responseParts: [{
    functionResponse: {
      id: request.callId,
      name: request.name,
      response: { error: error.message },
    },
  }],
  resultDisplay: error.message,
  errorType,
  contentLength: error.message.length,
});

2.4 超时策略

1. AbortSignal 机制

系统使用 AbortSignal 实现超时和取消控制:

调度层面的取消

schedule 方法中:

schedule(
  request: ToolCallRequestInfo | ToolCallRequestInfo[],
  signal: AbortSignal,
): Promise<void> {
  if (this.isRunning() || this.isScheduling) {
    return new Promise((resolve, reject) => {
      const abortHandler = () => {
        // 从队列中移除请求
        const index = this.requestQueue.findIndex(
          (item) => item.request === request,
        );
        if (index > -1) {
          this.requestQueue.splice(index, 1);
          reject(new Error('Tool call cancelled while in queue.'));
        }
      };
      
      signal.addEventListener('abort', abortHandler, { once: true });
      // ...
    });
  }
}

执行层面的取消

在工具执行过程中:

  1. 执行前检查
if (signal.aborted) {
  this.setStatusInternal(
    reqInfo.callId,
    'cancelled',
    'Tool call cancelled by user.',
  );
  continue;
}
  1. 执行后检查
.then(async (toolResult: ToolResult) => {
  if (signal.aborted) {
    this.setStatusInternal(
      callId,
      'cancelled',
      'User cancelled tool execution.',
    );
    return;
  }
  // 处理成功结果
})

2. Shell 工具特殊处理

Shell 工具有特殊的超时和取消机制:

进程 ID 跟踪

if (invocation instanceof ShellToolInvocation) {
  const setPidCallback = (pid: number) => {
    this.toolCalls = this.toolCalls.map((tc) =>
      tc.request.callId === callId && tc.status === 'executing'
        ? { ...tc, pid }
        : tc,
    );
    this.notifyToolCallsUpdate();
  };
  promise = invocation.execute(
    signal,
    liveOutputCallback,
    shellExecutionConfig,
    setPidCallback,
  );
}

Shell 执行配置

系统通过 ShellExecutionConfig 配置 Shell 执行环境:

export interface ShellExecutionConfig {
  terminalWidth?: number;
  terminalHeight?: number;
  pager?: string;
  showColor?: boolean;
  defaultFg?: string;
  defaultBg?: string;
}

3. 输出截断机制

为防止输出过大导致内存问题,系统实现了输出截断:

if (
  typeof content === 'string' &&
  toolName === ShellTool.Name &&
  this.config.getEnableToolOutputTruncation() &&
  this.config.getTruncateToolOutputThreshold() > 0 &&
  this.config.getTruncateToolOutputLines() > 0
) {
  const truncatedResult = await truncateAndSaveToFile(
    content,
    callId,
    this.config.storage.getProjectTempDir(),
    threshold,
    lines,
  );
  content = truncatedResult.content;
  outputFile = truncatedResult.outputFile;
}

如果我们要构建自己的 Agent 系统,Gemini CLI 有这些值得学习的地方:

  1. 工具发现机制:三层发现机制很灵活,既有内置工具保底,又能动态扩展。
  2. 状态机调度:清晰的状态流转,便于调试和监控。
  3. 环境感知:根据运行环境自动调整行为,提升用户体验。
  4. MCP 协议支持:接入标准协议,获得生态系统支持。
  5. 声明式工具定义:工具定义和实现分离,接口清晰。

2.5 潜在的问题

Gemini 这种设计也有一些潜在问题:

  1. 上下文膨胀:所有工具都提供给 LLM,可能导致 token 消耗增加。
  2. 工具选择不可控:完全依赖 LLM 选择,缺少人为干预手段。
  3. 调试困难:当工具很多时,难以追踪 LLM 为什么选择某个工具。

3. Shopify Sidekick 的工具管理策略

文章地址:https://shopify.engineering/building-production-ready-agentic-systems

Shopify 团队在其文章中告诉我们一个他们踩的坑:工具数量的增长不是线性问题,而是指数级复杂度问题

他们把这个过程分成了三个阶段:

0-20 个工具:蜜月期。每个工具职责清晰,调试简单,系统行为可预测。这个阶段你会觉得”Agent 开发也不过如此嘛”。

20-50 个工具:混乱期。工具边界开始模糊,组合使用时产生意想不到的结果。这时候你开始怀疑人生:”为什么调用查询工具的同时会触发邮件发送?”

50+ 个工具:崩溃期。同一个任务有多种实现路径,系统变得无法理解。调试像在迷宫里找出口,修一个 bug 引入三个新 bug。

当工具数量超过 30 个后,整个系统变得难以维护。Shopify 把这个问题叫做”Death by a Thousand Instructions”(千条指令之死),系统提示词变成了一个充满特殊情况、相互冲突的指导和边缘案例处理的怪物。

3.1 Just-in-Time Instructions

面对工具管理的混乱,Shopify 团队找到了一个巧妙的解决方案:Just-in-Time (JIT) Instructions

这个思路其实很简单,可能的实现如下:

# 传统方式:所有指令都塞在系统提示词里
system_prompt = """
你是一个AI助手...
当用户查询客户时,使用customer_query工具...
当用户需要发邮件时,先检查权限...
如果是VIP客户,要特别注意...
处理订单时要考虑库存...
(还有500行各种规则)
"""

# JIT方式:按需提供指令
def get_tool_instructions(tool_name, context):
    if tool_name == "customer_query":
        if context.is_filtering:
            return "使用customer_tags进行标签筛选,使用customer_status进行状态筛选..."
        else:
            return "直接查询客户基本信息..."
    # 只在需要时提供相关指令

这种设计的好处:

  1. 上下文精准:LLM 只看到当前需要的指令,不会被无关信息干扰。
  2. 缓存友好:核心系统提示词保持稳定,可以利用 LLM 的提示词缓存,提高响应速度。
  3. 灵活迭代:可以根据不同场景、用户群体、A/B 测试动态调整指令,不需要改动核心系统。
  4. 可维护性:每个工具的指令独立管理,修改一个工具的行为不会影响其他工具。

4. 小结

OpenManus 体现了优雅设计,Gemini CLI 展示简化尝试,而 Shopify Sidekick 展示了实战中的智慧。

当我们要构建生产级的 Agent 系统:

  • 不要低估工具管理的复杂性
  • 不要高估 LLM 的工具选择能力
  • 不要忽视生产环境的残酷性

Agent 系统不是一次性工程,而是需要不断进化的。我们需要不断学习、调整、优化,才能构建出真正可靠的 AI Agent。

当我们的 Agent 要服务真实用户时,严谨的工程实践比炫酷的技术更重要。这可能不够酷,但这是通往生产环境的必经之路。

以上。

Agent 核心策略:Manus、Gemini CLI 和 Claude Code 的上下文压缩策略和细节

做 AI Agent 开发的都需要考虑上下文爆炸的问题,不仅仅是成本问题,还有性能问题。

许多团队选择了压缩策略。但过度激进的压缩不可避免地导致信息丢失。

这背后的根本矛盾在于:Agent 需要基于完整的历史状态来决策下一步行动,但我们无法预知当前的某个观察细节是否会在未来的某个关键时刻变得至关重要。

前面一篇文章讲了整个上下文的管理策略,今天着重聊一下上下文管理的压缩策略和细节。

下面根据 Manus、Gemini CLI 和 Claude Code 这三个项目的源码来聊一下上下文的压缩。

三个产品,有三个不同的选择,或者说设计哲学。

Manus 的选择是:永不丢失。 他们认为,从逻辑角度看,任何不可逆的压缩都带有风险。所以他们选择了一条看似”笨拙”但实际上很聪明的路:把文件系统当作”终极上下文”。

Claude Code 的选择是:极限压榨。 92% 的压缩触发阈值,这个数字相当激进。他们想要榨干上下文窗口的每一个 token,直到最后一刻才开始压缩。

Gemini CLI 的选择是:稳健保守。 70% 就开始压缩,宁可频繁一点,也要保证系统的稳定性。

这三种选择没有对错,只是适用场景不同。接下来我们逐个分析。

1. Manus

Manus 的压缩最让我印象深刻的是其在可恢复性上的策略。

Manus 团队认为:任何不可逆的压缩都带有风险。你永远不知道现在丢掉的信息是不是未来解决问题的关键。

他们选择了不做真正的删除,而是将其外部化存储。

传统做法是把所有观察结果都塞进上下文里。比如让 Agent 读一个网页,它会把整个 HTML 内容都存下来,可能就是上万个 token。Manus 不这么干。

当 Agent 访问网页时,Manus 只在上下文中保留 URL 和简短的描述,完整的网页内容并不保存。需要重新查看网页内容时,通过保留的 URL 重新获取即可。这就像是你在笔记本上记录”参见第 23 页的图表”,而不是把整个图表重新画一遍。

文档处理也是同样的逻辑。一个 100 页的 PDF 文档,Manus 不会把全部内容放进上下文,而是只记录文档路径、页数、最后访问的位置等元信息。当 Agent 需要查看具体内容时,再通过文件路径读取相应的页面。

Manus 把文件系统视为”终极上下文”——一个容量几乎无限、天然持久化、Agent 可以直接操作的外部记忆系统。

文件系统的层级结构天然适合组织信息。Agent 可以创建不同的目录来分类存储不同类型的信息:项目背景放一个文件夹,技术细节放另一个文件夹,错误日志单独存放。需要时按图索骥,而不是在一个巨大的上下文中大海捞针。

这不是简单的存储。Manus 训练模型学会主动使用文件系统来管理自己的「记忆」。当发现重要信息时,Agent 会主动将其写入特定的文件中,而不是试图把所有东西都记在上下文里。就像一个经验丰富的研究员,知道什么该记在脑子里,什么该写在笔记本上,什么该归档保存。

在 Manus 团队对外的文章开头,指出了为什么必须要有压缩策略:

第一,观察结果可能非常庞大。与网页或 PDF 等非结构化数据交互时,一次观察就可能产生数万个 token。如果不压缩,可能一两次操作就把上下文占满了。

第二,模型性能会下降。这是个很多人忽视的问题。即使模型声称支持 200k 的上下文窗口,但实际使用中,超过一定长度后,模型的注意力机制效率会显著下降,响应质量也会变差。这就像人的工作记忆一样,信息太多反而会降低处理效率。

第三,成本考虑。长输入意味着高成本,即使使用了前缀缓存等优化技术,成本依然可观。特别是在需要大量交互的场景下,成本会快速累积。

在具体实现过程中,可恢复压缩有几个关键点:

保留最小必要信息。对于每个外部资源,只保留能够重新获取它的最小信息集。网页保留 URL,文档保留路径,API 响应保留请求参数。这些信息占用的空间极小,但足以在需要时恢复完整内容。

智能的重新加载时机。不是每次提到某个资源就重新加载,而是根据上下文判断是否真的需要详细内容。如果只是确认文件存在,就不需要读取内容;如果要分析具体细节,才触发加载。

缓存机制。虽然内容不在上下文中,但 Manus 会在本地维护一个缓存。最近访问过的资源会暂时保留,避免频繁的重复加载。这个缓存是独立于上下文的,不占用宝贵的 token 额度。

2. Claude Code

Claude Code 的策略完全是另一个极端——他们要把上下文用到极致。

2.1 92% 的阈值

这个数字有一些讲究。留 8% 的缓冲区既保证了压缩过程有足够的时间完成,又避免了频繁触发压缩带来的性能开销。更重要的是,这个缓冲区给了系统一个「反悔」的机会——如果压缩质量不达标,还有空间执行降级策略。

const COMPRESSION_CONFIG = {
  threshold0.92,           // 92%阈值触发
  triggerVariable"h11",    // h11 = 0.92
  compressionModel"J7()",  // 专用压缩模型
  preserveStructuretrue    // 保持8段结构
};

2.2 八段式结构化摘要

Claude Code 的压缩不是简单的截断或摘要,而是八段式结构。这个结构我们可以学习一下:

const COMPRESSION_SECTIONS = [
  "1. Primary Request and Intent",    // 主要请求和意图
  "2. Key Technical Concepts",        // 关键技术概念
  "3. Files and Code Sections",       // 文件和代码段
  "4. Errors and fixes",              // 错误和修复
  "5. Problem Solving",               // 问题解决
  "6. All user messages",             // 所有用户消息
  "7. Pending Tasks",                 // 待处理任务
  "8. Current Work"                   // 当前工作
];

每一段都有明确的目的和优先级。「主要请求和意图」确保 Agent 永远不会忘记用户最初想要什么;「关键技术概念」保留重要的技术决策和约束条件;「错误和修复」避免重复踩坑;「所有用户消息」则保证用户的原始表达不会丢失。

这种结构的好处是,即使经过多次压缩,Agent 仍然能保持工作的连贯性。关键信息都在,只是细节被逐步抽象化了。

2.3 专用压缩模型 J7

Claude Code 使用了一个专门的压缩模型 J7 来处理上下文压缩。这不是主模型,而是一个专门优化过的模型,它的任务就是理解长对话并生成高质量的结构化摘要。

async function contextCompression(currentContext{
  // 检查压缩条件
  if (currentContext.tokenRatio < h11) {
    return currentContext;  // 无需压缩
  }
  
  // 调用专用压缩模型
  const compressionPrompt = await AU2.generatePrompt(currentContext);
  const compressedSummary = await J7(compressionPrompt);
  
  // 构建新的上下文
  const newContext = {
    summary: compressedSummary,
    recentMessages: currentContext.recent(5),  // 保留最近5条
    currentTask: currentContext.activeTask
  };
  
  return newContext;
}

AU2 负责生成压缩提示词,它会分析当前上下文,提取关键信息,然后构造一个结构化的提示词给 J7。J7 处理后返回符合八段式结构的压缩摘要。

2.4 上下文生命周期管理

Claude Code 把上下文当作有生命周期的实体来管理。这个设计理念很先进——上下文不是静态的数据,而是动态演化的有机体。

class ContextManager {
  constructor() {
    this.compressionThreshold = 0.92;  // h11 = 0.92
    this.compressionModel = "J7";      // 专用模型
  }
  
  async manageContext(currentContext, newInput) {
    // 1. 上下文更新
    const updatedContext = this.appendToContext(currentContext, newInput);
    
    // 2. 令牌使用量检查
    const tokenUsage = await this.calculateTokenUsage(updatedContext);
    
    // 3. 压缩触发判断
    if (tokenUsage.ratio >= this.compressionThreshold) {
      // 4. 八段式压缩执行
      const compressionPrompt = await AU2.generateCompressionPrompt(updatedContext);
      const compressedSummary = await this.compressionModel.generate(compressionPrompt);
      
      // 5. 新上下文构建
      return this.buildCompressedContext(compressedSummary, updatedContext);
    }
    
    return updatedContext;
  }
}

整个流程是自动化的。每次有新的输入,系统都会评估是否需要压缩。压缩不是一次性的动作,而是持续的过程。随着对话的进行,早期的详细内容会逐渐被抽象化,但关键信息始终保留。

2.5 优雅降级机制

当压缩失败时,系统不会死板地报错或者强行应用低质量的压缩结果,而是有一整套 Plan B、Plan C。这种”永不放弃”的设计理念,让系统在各种极端情况下都能稳定运行。

降级策略包括:

  • 自适应重压缩:如果首次压缩质量不佳,会调整参数重试
  • 混合模式保留:压缩旧内容,但完整保留最近的交互
  • 保守截断:最坏情况下,至少保证系统能继续运行

2.6 压缩后的信息恢复

虽然 Claude Code 的压缩是有损的,但它通过巧妙的设计最小化了信息损失的影响。压缩后的八段式摘要不是简单的文本,而是结构化的信息,包含了足够的上下文让 Agent 能够理解之前发生了什么,需要做什么。

特别值得一提的是第 6 段”All user messages”。即使其他内容被压缩了,用户的所有消息都会以某种形式保留。这确保了用户的意图和需求不会在压缩过程中丢失。

2.7 实践指南

Claude Code 在实践中还有一些最佳实践:

  • 定期使用 /compact 命令压缩长对话:用户可以主动触发压缩,不必等到自动触发
  • 在上下文警告出现时及时处理:系统会在接近阈值时发出警告,用户应该及时响应
  • 通过 Claude.md 文件保存重要信息:将关键信息外部化,减少上下文消耗

3. Gemini CLI

Gemini CLI 选择了一条中庸之道,或者说是实用之道。Gemini CLI 项目开源了,这部分的说明会多一些。

3.1 70/30?

Gemini CLI 选择了 70% 作为压缩触发点,30% 作为保留比例。这个比例我们也可以参考学习一下:

为什么是 70% 而不是 92%

  • 更早介入,避免紧急压缩导致的卡顿
  • 给压缩过程留出充足的缓冲空间
  • 适合轻量级应用场景,不追求极限性能

30% 保留的合理性:

  • 刚好覆盖最近 5-10 轮对话
  • 足够维持上下文连续性
  • 不会让用户感觉”突然失忆”

共背后的逻辑是:宁可频繁一点地压缩,也要保证每次压缩都是从容的、高质量的。

3.2 精选历史提取

Gemini CLI 有个独特的概念叫精选历史”。不是所有的历史都值得保留,系统会智能地筛选有效内容:

function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
  if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
    return [];
  }
  const curatedHistory: Content[] = [];
  const length = comprehensiveHistory.length;
  let i = 0;
  while (i < length) {
    // 用户轮次直接保留
    if (comprehensiveHistory[i].role === 'user') {
      curatedHistory.push(comprehensiveHistory[i]);
      i++;
    } else {
      // 处理模型轮次
      const modelOutput: Content[] = [];
      let isValid = true;
      // 收集连续的模型轮次
      while (i < length && comprehensiveHistory[i].role === 'model') {
        modelOutput.push(comprehensiveHistory[i]);
        // 检查内容有效性
        if (isValid && !isValidContent(comprehensiveHistory[i])) {
          isValid = false;
        }
        i++;
      }
      // 只有当所有模型轮次都有效时才保留
      if (isValid) {
        curatedHistory.push(...modelOutput);
      }
    }
  }
  return curatedHistory;
}

这个策略的巧妙之处在于:

  • 用户输入全部保留:所有用户输入都被视为重要信息,无条件保留
  • 模型轮次有条件保留:连续的模型轮次被视为一个整体进行评估
  • 全有或全无的处理:要么全部保留,要么全部丢弃,避免了复杂的部分保留逻辑

3.3 内容有效性判断

什么样的内容会被认为是无效的?Gemini CLI 有明确的标准:

function isValidContent(content: Content): boolean {
  // 检查 parts 数组是否存在且非空
  if (content.parts === undefined || content.parts.length === 0) {
    return false;
  }
  for (const part of content.parts) {
    // 检查 part 是否为空
    if (part === undefined || Object.keys(part).length === 0) {
      return false;
    }
    // 检查非思考类型的 part 是否有空文本
    if (!part.thought && part.text !== undefined && part.text === '') {
      return false;
    }
  }
  return true;
}

无效内容包括:空响应、错误输出、中断的流式响应等。这种预过滤机制确保进入压缩流程的都是高质量的内容。

3.4 五段式结构化摘要

相比 Claude Code 的八段式,Gemini CLI 的五段式更简洁,但涵盖了所有关键信息:

1. overall_goal - 用户的主要目标
2. key_knowledge - 重要技术知识和决策
3. file_system_state - 文件系统当前状态
4. recent_actions - 最近执行的重要操作
5. current_plan - 当前执行计划

压缩时,系统会生成 XML 格式的结构化摘要。这种格式的好处是结构清晰,LLM 容易理解和生成,同时也便于后续的解析和处理。

3.5 基于 Token 的智能压缩

Gemini CLI 的压缩不是简单的定时触发,而是基于精确的 token 计算:

async tryCompressChat(
  prompt_id: string,
  force: boolean = false,
): Promise<ChatCompressionInfo | null> {
  const curatedHistory = this.getChat().getHistory(true);

  // 空历史不压缩
  if (curatedHistory.length === 0) {
    return null;
  }

  const model = this.config.getModel();

  // 计算当前历史的 token 数量
  const { totalTokens: originalTokenCount } =
    await this.getContentGenerator().countTokens({
      model,
      contents: curatedHistory,
    });

  // 获取压缩阈值配置
  const contextPercentageThreshold =
    this.config.getChatCompression()?.contextPercentageThreshold;

  // 如果未强制压缩且 token 数量低于阈值,则不压缩
  if (!force) {
    const threshold =
      contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD; // 默认 0.7
    if (originalTokenCount < threshold * tokenLimit(model)) {
      return null;
    }
  }

  // 计算压缩点,保留最后 30% 的历史
  let compressBeforeIndex = findIndexAfterFraction(
    curatedHistory,
    1 - COMPRESSION_PRESERVE_THRESHOLD, // COMPRESSION_PRESERVE_THRESHOLD = 0.3
  );

  // 确保压缩点在用户轮次开始处
  while (
    compressBeforeIndex < curatedHistory.length &&
    (curatedHistory[compressBeforeIndex]?.role === 'model' ||
      isFunctionResponse(curatedHistory[compressBeforeIndex]))
  ) {
    compressBeforeIndex++;
  }

  // 分割历史为需要压缩和需要保留的部分
  const historyToCompress = curatedHistory.slice(0, compressBeforeIndex);
  const historyToKeep = curatedHistory.slice(compressBeforeIndex);

  // 使用 LLM 生成历史摘要
  this.getChat().setHistory(historyToCompress);
  const { text: summary } = await this.getChat().sendMessage(
    {
      message: {
        text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
      },
      config: {
        systemInstruction: { text: getCompressionPrompt() },
      },
    },
    prompt_id,
  );

  // 创建新的聊天历史,包含摘要和保留的部分
  this.chat = await this.startChat([
    {
      role: 'user',
      parts: [{ text: summary }],
    },
    {
      role: 'model',
      parts: [{ text: 'Got it. Thanks for the additional context!' }],
    },
    ...historyToKeep,
  ]);
}

这个实现有几个细节值得注意:

  • 支持强制压缩:通过 force 参数,用户可以主动触发压缩
  • 智能分割点选择:确保压缩点在用户轮次开始,避免打断对话逻辑
  • 两阶段压缩:先生成摘要,再重建对话历史

3.6 多层压缩机制

Gemini CLI 的压缩是分层进行的,每一层都有特定的目标:

第一层:内容过滤:过滤掉无效内容、thought 类型的部分,确保进入下一层的都是有价值的信息。

第二层:内容整合:合并相邻的同类内容,比如连续的纯文本 Part 会被合并成一个,减少结构冗余。

第三层:智能摘要:当 token 使用量超过阈值时,触发 LLM 生成结构化摘要。

第四层:保护机制:确保关键信息不被压缩丢失,比如用户的最新指令、正在进行的任务等。

3.7 模型适配的 Token 限制

不同的模型有不同的 token 限制,Gemini CLI 对此有精细的适配:

export function tokenLimit(model: Model): TokenCount {
  switch (model) {
    case 'gemini-1.5-pro':
      return 2_097_152;
    case 'gemini-1.5-flash':
    case 'gemini-2.5-pro':
    case 'gemini-2.5-flash':
    case 'gemini-2.0-flash':
      return 1_048_576;
    case 'gemini-2.0-flash-preview-image-generation':
      return 32_000;
    default:
      return DEFAULT_TOKEN_LIMIT; // 1_048_576
  }
}

系统会根据使用的模型自动调整压缩策略。对于支持超长上下文的模型(如 gemini-1.5-pro 的 200 万 token),可以更宽松;对于受限的模型,会更积极地压缩。

3.8 历史记录的精细处理

recordHistory 方法负责记录和处理历史,实施了多个优化策略:

  1. 避免重复:不会重复添加相同的用户输入
  2. 过滤思考过程:thought 类型的 Part 会被过滤掉,不进入最终历史
  3. 合并优化:相邻的模型轮次会被合并,相邻的纯文本也会合并
  4. 占位符策略:如果模型没有有效输出,会添加空的占位符保持结构完整

3.9 压缩的用户体验设计

Gemini CLI 特别注重压缩对用户体验的影响:

  • 无感压缩:70% 的阈值确保压缩发生在用户察觉之前
  • 连续性保持:保留 30% 的最新历史,确保当前话题的连贯性
  • 透明反馈:压缩前后的 token 数量变化会被记录和报告

4. 写在最后

研究完这三个项目的源码,我最大的感受是:压缩策略的选择,本质上是对「什么是重要的」这个问题的回答。 Manus 说「所有信息都可能重要,所以我不删除,只是暂时收起来」;Claude Code 说「结构化的摘要比原始细节更重要」;Gemini CLI 说「用户体验比技术指标更重要」。三种回答,三种哲学。

这让我想起一句话:在 AI 时代,真正稀缺的不是信息,而是注意力。 上下文压缩就是在教 AI 如何分配注意力——什么该记住,什么可以忘记,什么需要随时能找回来。

这是人类智慧的核心能力之一。我们每天都在做类似的决策:重要的事情记在心里,次要的写在本子上,琐碎的存在手机里。Manus、Claude Code 和 Gemini CLI 只是用不同的方式在教 AI 做同样的事。

没有完美的压缩策略,只有最适合你场景的策略。 选择哪种策略不重要,重要的是理解它们背后的设计智慧,然后根据自己的需求做出明智的选择。

以上。