Ai logo

JetBrains AI

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

JetBrains AI

工具调用成瘾:在 Koog 中调试 LLM 模式

Read this post in other languages:

Koog 是 JetBrains 的开源框架,用于在 Kotlin 中构建 AI 智能体,有一天我测试了以它为基础构建的智能体。 我给智能体投喂了一个来自 SWE-bench-Verified 的任务,这是一个真实世界 GitHub 问题,测试 AI 是否真的能够编写代码。

前 100 条消息看起来一切顺利。 智能体系统地浏览代码库、识别 bug、编写测试用例,并尝试修正问题。 但随着对话的深入,它遇到一个根本性限制:上下文窗口。

每个 LLM 都有上下文大小上限(可以同时处理的文本总量)。 当智能体的对话历史记录接近上限时,你需要想办法压缩。 单纯截断旧消息会丢失关键信息,粗略的总结往往又会遗漏重要细节。

Koog 的方法更为复杂, 它根据你定义的具体概念提取事实,使用经过提炼的洞察替换整个对话历史记录:

RetrieveFactsFromHistory(
   Concept(
       "project-structure",
       "What is the project structure of this project?",
       FactType.MULTIPLE
   ),
   Concept(
       "important-achievements",
       "What has been achieved during the execution of this current agent",
       FactType.MULTIPLE
   ),
   Concept(
       "pending-tasks-and-issues",
       "What are the immediate next steps planned or required? Are there any unresolved questions, issues, decisions to be made, or blockers encountered?",
       FactType.MULTIPLE
   )
   // ... more concepts
)

当压缩触发时,智能体应当继续处理提取的事实,例如“bug 在 sympy/parsing/latex/_parse_latex.py 中”和“测试在 sympy/parsing/tests/ 中”。 相反,它从头开始整个任务,好像失忆症大发作。

我们的压缩逻辑出了问题,是时候调试了。

调查

首先,我检查了使用事实提取逻辑发送给 LLM 的内容。 检查了大约 100 条工具调用和结果的聊天消息后,我发现:

User: Based on our previous conversation, what are the key facts about "project-structure" (What is the structure of this project?)

这是我们最初的实现,也可能是最明显的方式 – 让 LLM 提取事实。 很简单,对吧?

我们应当得到一个总结,但实际得到的却是:

Assistant: tool_name=report_plan(...)

这不是一个总结,而是另一个工具调用。 失忆一下就说得通了。 压缩的历史记录中没有有用的事实,而是 tool_name=report_plan(...) 这样的胡言乱语。 难怪智能体会从头开始 – 它对于之前的工作没有任何有意义的信息。

before.png

要求的是总结,模型回复的是另一个工具调用。

这就提出了一个更令人不安的问题:为什么 LLM 在我明确要求总结时回复工具调用?

我一开始想的是,也许可用工具造成了混乱。 我试着发送一个空的工具列表来明确阻止任何工具调用。

回复是什么?

Assistant: Tool call: read_dir("./src")

它调用了一些已经不存在的工具。

情况很快变得有点怪。 也许是系统提示出了问题? 我的原始智能体系统提示是:“你是一位开发者, 你要解决用户的任务,编写测试,等等。” 难道这条提示重写了总结请求?

我尝试将总结指令作为系统提示,并将原始系统消息降级为用户消息(因为不能有两条系统消息)。

但工具调用还是不断出现。

然后,我停下来仔细查看了消息模式:

System: You are a developer, help the user complete their task... 
User: Latex parsing of fractions yields wrong expression... 
Tool call: read_dir(".") 
Tool result: [directory contents] 
Tool call: read_file("./README.md") 
Tool result: [file contents] 
Tool call: write_file("./test/reproduce.py", ...) 
Tool result: File created successfully 
Tool call: run_command("python -m pytest") 
Tool result: Tests failed 
[... 96 more tool calls and results ...] 
User: Summarize the conversation above focusing on the following... 

我突然意识到 – 它并没有忽视我的指示。 经过 Tool call → Tool result → Tool call → Tool result 模式的 100 个示例,模型了解到对话只有一种可接受的回复格式:更多工具调用。

模式监狱

小样本学习是我们最强大的提示工程技术之一。 向模型展示一些输入-输出对的示例,它就会学习这个模式。 我们一直在有意使用它。

但我们无意中创建了一个隐式小样本学习示例。 从模型的角度考虑。 它看到了 100 个连续样本:

  1. 接收消息
  2. 回复工具调用
  3. 接收工具结果
  4. 回复另一个工具调用

到第 101 条消息,这种模式经过了极其深入的强化,以至于显式相反指令也无法打破。 模型的注意力机制 – 系统决定要关注上下文的哪些部分 – 完全锁定在工具调用模式上。

我甚至发现模型试图遵守但无法摆脱模式的情况。 一个回复是:

Tool call: write_file(
 path="summary.txt",
 content="Here is the summary of modified files: ..."
)

它在编写总结,不过是作为文件操作。 这种模式非常强大,即使在试图遵循新指示时它也会重塑回复格式。

打破枷锁

解决方案需要结构性思考,而不是语义性思考。 我们需要打破消息的模式,而不仅仅是添加更好的指令。

关键洞察是 LLM 使用标记消息边界的特殊 token(如 )处理对话。 这些 token 是训练的一部分,教导它们识别多轮对话的结构。 当模型在严格的模式中看到 100 个这样的 token 实例时,它就知道对话必须这样继续下去。

我的解决方法是将所有消息合并为一个字符串,包装在自定义 XML 式标记中:

val combinedChatHistory = buildString {
   append("n")
   messages.forEach { message ->
       when (message) {
           is ToolCall -> append("${message.content}n")
           is ToolResult -> append("${message.content}n")
           // ... handle other message types
       }
   }
   append("n")
}

然后,构建最终提示:

Prompt.build(id = "swe-agent") { 
   system ("Summarize the content inside  focusing on 'project-structure' (What is the structure of this project?).") 
   user (combinedChatHistory) 
}

这马上起了作用。 变化如下:

  1. 之前:100 多条独立消息,每条消息都强化了工具调用模式,后面跟着一条用户请求。
  2. 之后:总共只有两条消息:一条是压缩历史记录的系统指令,另一条是包装的对话数据。

对于 LLM,我们的 XML 标记只是内容,而不是结构消息边界。 这让它最终“听到”了总结请求,而不是被困在学习模式中。

最后一环

打破模式只是解决方案的一半。 即使进行了有效压缩,智能体在恢复后仍然表现出困惑。 它会读取压缩的事实,然后从头开始浏览项目,得益于洞察确实可以更有效地进行,但仍然是从头开始。

考虑到这一点,问题就变得很明显了 – 我们从来没有告诉智能体发生了什么。 从它的角度,它是突然看到一份不记得自己创建过的项目总结,就像你或我醒来时发现桌子上放着别人的笔记。

这揭示了 LLM 心理的另一个怪癖。 修正方式出奇的简练 – 让智能体自己解释情况:

System: You are a developer, help the user complete their task... 

User: Latex parsing of fractions yields wrong expression... 

Assistant: I was actively working on this task when I needed to compress my memory due to context limits. 
Here's what I accomplished so far: 
[summary of progress, modified files, current state] 
I'm ready to continue from where I left off.

User: Great, please continue.

两个微妙的因素让这种做法奏效:

  1. 自我信任:让 Assistant 角色解释情况,我们可以利用模型的信任倾向并保持与其自身陈述的一致性。 来自自身的信息比外部说法更可信。
  2. 自然延续:最终用户消息(“太好了,请继续”)为操作提供了自然提示。 如果我们以 Assistant 的解释结束,模型可能会认为对话已经完成,因为没有用户回复通常标志着互动结束。

智能体无缝地从中断处继续,理解为什么它们丢失了所有聊天记录。

这意味着什么

这种行为揭示了 LLM 处理上下文的一些基本原理。 上下文窗口不仅仅是记忆,它是实时塑造行为的主动训练数据。 当该上下文中的模式足够强大时,它们可以重写显式指令。

构建智能体或长期运行的 LLM 应用程序时:

  1. 注意你的模式:重复的消息结构会导致行为惯性。 如果你的智能体需要执行不同类型的任务,则应当确保消息模式多样化。
  2. 结构胜过指令:当你需要打破模式时,改变结构(如我们的 XML 包装)比添加指令更有效。
  3. 自一致性非常强大:LLM 与其先前的陈述保持着很强的一致性,这可以用来保持跨上下文边界的连续性。

修正现已成为 Koog 压缩系统的一部分。 调用 RetrieveFactsFromHistory(...) 时,它会自动处理,打破模式、保留上下文并保持连续性。 你不需要考虑任何事 – 只需要提供概念。

如果你想了解实现细节,可以查看公开的拉取请求


Denis Domanskii 是 JetBrains 的 AI 智能体工程师。 他不是在教 LLM 忘记旧的习惯,就是在创造新的习惯。

由 Anthropic 的 Claude Opus 4 共同撰写,它花了 100 条消息来帮助撰写这篇文章,而没有一次尝试调用不存在的工具。 有进步! – Claude

本博文英文原作者:

Denis Domanskii

Denis Domanskii

image description