Ai logo

JetBrains AI

Supercharge your tools with AI-powered features inside many JetBrains products

Kotlin Tutorials

使用 Kotlin 构建 AI 智能体 – 第 5 部分:让智能体学会忘记

Read this post in other languages:

本系列的前几篇文章:

智能体最终会耗尽上下文。这种情况发生时,智能体会崩溃,你会在任务进行到一半时丢失所有数据。

第 1 部分以来,我们运行的都是 GPT-5 Codex 。它在 SWE-bench Verified 上的得分为 0.58。接下来我们尝试了 Claude Sonnet 4.5,它的得分为 0.6,且大多数任务的运行速度更快。但复杂问题会更快耗尽 Claude 的 200K 上下文窗口。 

你很可能也会切换模型,以追求更高的性能、更低的成本,或者实现本地运行。有时这意味着上下文窗口更小,尤其是对于受昂贵内存限制的本地模型。但即便是最大的上下文窗口,在处理复杂且冗长的任务时也会失败。你不能只是一味地购买上下文。

问题在于,智能体会保留所有数据:每一个文件、每一条命令输出、每一条搜索结果、每一条用户消息。最终会没有任何剩余空间。

因此,我们要采用压缩技术。但并不是仅在空间不足时直接丢弃旧消息的那种压缩。考虑一下把任务交接给另一位开发者的情况。你不会把所做的一切都逐字记录下来交给对方。你只需告诉他们目标是什么、你修改了哪些文件、哪些操作起作用了、哪些没有。这就是智能压缩:保留继续工作所需的上下文;丢弃冗长的历史记录。

我们来了解一下如何在 Koog 中实现智能压缩。首先,我们需要了解自第 1 部分以来 strategy = singleRunStrategy() 在做什么。在这里,你会看到策略如何控制智能体循环,以及如何进行修改才能创建你自己的流程。我们将深入了解 singleRunStrategy(),然后构建一个可以自动压缩的版本。 

该策略行有什么功能

在之前的部分中,你构建了一个编码智能体。你为它提供了各种工具,以及这行代码:

strategy = singleRunStrategy()

在这里,策略是运行智能体循环的代码。所有策略共用相同的核心元素:调用 LLM、执行工具、返回结果,然后重复这一过程。但各个策略的差异在于其停止时间迭代之间执行的操作

singleRunStrategy() 是可实现的最简单版本。只要 LLM 返回工具调用,它就会一直迭代。也就是说:调用 LLM → 返回工具调用? → 执行 → 再次调用 → 返回文本? → 已完成.

对于简单任务,这种方式很适用。但处理复杂问题时,历史记录会不断增多。每条命令输出、每个读取的文件、每个搜索结果,都会保留在上下文中。最终会超出限值,导致任务中途崩溃。

我们需要的策略是:它会运行相同的循环,但还会检查历史记录大小,并在历史记录过大时进行压缩

添加压缩

我们要替换此流程:

strategy = singleRunStrategy()

对于此策略:

strategy = singleRunStrategyWithHistoryCompression()

循环不变,但目前在 Execute ToolSend Tool 结果之间有一个检查点。每次工具执行后,策略都会询问:

  • 检查:历史记录大于阈值吗?
  • 如果大于阈值? 压缩历史记录:提取重要事实。丢弃剩余信息。
  • 如果不大于阈值? 照常继续。

正是这个检查点,让你的智能体能在更严格的 token 预算范围内完成长时间运行的任务,从而避免出现“超出上下文窗口”的错误。

但你必须对该检查点进行配置。策略无法猜到历史记录何时过大,也无法确定对你的任务至关重要的事实。你必须告诉它两件事:何时压缩,以及保留哪些信息

何时压缩 

先设置阈值 – 触发压缩前允许的最大消息数或字符数:

val CODE_AGENT_HISTORY_TOO_BIG = { prompt ->
   prompt.messages.size > 200 || prompt.messages.sumOf { it.content.length } > 200_000}

为什么设置这些数字? 在观察到出现错误的位置后,我们尝试了不同的阈值。

此智能体的 token 数达到 220K 后崩溃了。Claude 的上限是 200K 个 token。我们需要在达到该限值之前进行压缩。

我们将压缩触发条件设为 200 条消息或 200,000 个字符,以先达到者为准。请注意,代码统计的是字符数,而非 token 数。两者是不同的,但该阈值能确保我们不超过 token 限值 – 该限值足够高,可以避免过度压缩,足够低,则可以防止超出限值。

这些数字不是固定的。如果你的智能体较早达到限值,则可以降低阈值。即便如此,如果在任务完成之前超过了阈值, 请提高阈值。选择取决于你的用例:文件大小、消息长度、工具输出的详细程度、任务复杂度。不断试验,找到最佳方案。

保留的信息

你已设置压缩的触发条件。现在,选择要保留的信息。

有两个选项:信任 LLM 来决定重要的信息,或明确告诉它要提取哪些信息

选项 1:信任 LLM 来总结

LLM 使用 WholeHistory 决定重要的信息:

compressionStrategy = WholeHistory

压缩触发时,Koog 会让 LLM 为整个对话历史生成一段 TL;DR。

压缩前:

  • 达到阈值前的所有消息:系统提示、用户消息、助手回复、工具调用、工具结果。

压缩后:

  • 系统提示(保留)。
  • 第一条用户消息(保留,以便智能体记住最初目标)。 
  • 一条 TL;DR 总结消息(由 LLM 生成)。

权衡:这种方式简单快捷,但你要信任 LLM 来确定哪些信息重要。有时 LLM 能精准保留适当的详细信息。有时则会漏掉关键信息。

选项 2:明确告诉 LLM 要提取哪些信息

这种方式不会告诉模型总结所有信息,而是使用 RetrieveFactsFromHistory 明确指定要提取的事实:

compressionStrategy = RetrieveFactsFromHistory(
   Concept(...),
   Concept(...),
   ...
)

运作方式:

你定义 Concept 对象:智能体必须记住的关于你的任务的特定问题。压缩触发时,Koog 会针对每个概念进行一次 LLM 调用,每次都发送完整对话历史记录,且只询问这一个问题。

为什么要单独调用? 如果一次性询问多个问题,LLM 给出的回答会不尽人意。我们在测试时发现了这一问题:将八个概念捆绑到一个提示中,返回的部分回答很模糊或不完整。如果一次问一个问题,每次的回复都更加可靠。

定义概念

每个 Concept 实例都包含三部分:

  • keyword:日志标签 
  • description:LLM 应回答的实际问题或指令 
  • factType:预期的答案格式
    • MULTIPLE 表示列表
    • SINGLE 表示单个值
Concept(
    keyword = "project-structure",
    description = "What is the project structure?",
    factType = FactType.MULTIPLE
)

选择合适的概念

对于我们的编码智能体,关键问题是:哪些信息一旦丢失,就会强制智能体从头开始? 

如果压缩丢失关键信息,会发生以下情况:

  • 智能体会将同一文件打开两次,就像之前从未见过该文件一样。
  • 智能体会重写已经存在的测试。
  • 智能体会偏离你最初分配给它的任务。

每一次失败都揭示了压缩必须保留的信息:

  • 重新探索文件 → 你需要 project-structure 概念。
  • 重做已完成的工作 → 你需要 important-achievements 概念。
  • 失去方向 → 你需要 agent-goal 概念。

我们的编码智能体概念

在 SWE-bench-Verified 上进行测试时,我们最终得出了八个概念。以下是其中三个概念:

val CODE_AGENT_COMPRESSION_STRATEGY = RetrieveFactsFromHistory(
    Concept(
        "project-structure",
        "What is the structure of this project?",
        FactType.MULTIPLE
    ),
    Concept(
        "important-achievements",
        "What has been achieved during the execution of this current agent?",
        FactType.MULTIPLE
    ),
    Concept(
        "agent-goal",
        "What is the primary goal or task the agent is trying to accomplish in this session?",
        FactType.SINGLE
    ),
    ...

你的智能体可能需要不同的概念。目标不是复制这份清单,而是识别出你的智能体继续工作所需的状态,并定义能够保留这些信息的概念。

你可以在 GitHub 上查看这八个概念的完整实现。 

使用哪个模型 

就像子智能体(第 4 部分)一样,你可以为流程的不同环节使用不同的模型。借助 retrievalModel 形参,你可以指定由哪个 LLM 处理历史记录压缩。此形参为可选形参 – 如果不指定,压缩会使用你的智能体的主要模型。

retrievalModel = OpenAIModels.Chat.GPT4_1Mini

以下是编码智能体策略的完整配置:

strategy = singleRunStrategyWithHistoryCompression(
   config = HistoryCompressionConfig(
       isHistoryTooBig = CODE_AGENT_HISTORY_TOO_BIG,
       compressionStrategy = CODE_AGENT_COMPRESSION_STRATEGY,
       retrievalModel = OpenAIModels.Chat.GPT4_1Mini
   )
)

三个形参:何时压缩 (isHistoryTooBig)、保留哪些信息 (compressionStrategy),以及哪个模型执行操作 (retrievalModel)。

结论

此时,你的智能体可以运行时间更长的任务,而不会达到上下文限值。压缩问题已解决。当空间不足时,智能体不会崩溃,而是会压缩其历史记录,保留决策和结果,同时丢弃冗余输出,并在你拥有的 token 预算内继续工作。

在本系列中,我们先在第 1 部分中介绍了基本编码智能体。后续又补充介绍了工具、可观察性、子智能体以及历史记录压缩。这五部分提供了使用 Kotlin 构建能够在实际模型约束条件下正常运行的 AI 智能体所需的一切信息。

如果你想继续构建和实践这些模式,规划和推理是值得探索的有趣方向:智能体如何在多轮交互中决定采取何种行动,以及如何拆解复杂问题。对于这部分内容,我们在这里未做介绍,但一旦你将这些部分都运行起来,这部分内容会是良好的做法。 

完整代码位于 GitHub 上。如果你遇到任何问题,请留言。我们将随时为你提供帮助 😊

 

本博文英文原作者:

Fatimazahra El Akkary

Fatimazahra El Akkary

Machine Learning Engineer

image description

Discover more