JetBrains AI
Supercharge your tools with AI-powered features inside many JetBrains products
工具调用成瘾:在 Koog 中调试 LLM 模式
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(...)
这样的胡言乱语。 难怪智能体会从头开始 – 它对于之前的工作没有任何有意义的信息。
要求的是总结,模型回复的是另一个工具调用。
这就提出了一个更令人不安的问题:为什么 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 个连续样本:
- 接收消息
- 回复工具调用
- 接收工具结果
- 回复另一个工具调用
到第 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) }
这马上起了作用。 变化如下:
- 之前:100 多条独立消息,每条消息都强化了工具调用模式,后面跟着一条用户请求。
- 之后:总共只有两条消息:一条是压缩历史记录的系统指令,另一条是包装的对话数据。
对于 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.
两个微妙的因素让这种做法奏效:
- 自我信任:让 Assistant 角色解释情况,我们可以利用模型的信任倾向并保持与其自身陈述的一致性。 来自自身的信息比外部说法更可信。
- 自然延续:最终用户消息(“太好了,请继续”)为操作提供了自然提示。 如果我们以 Assistant 的解释结束,模型可能会认为对话已经完成,因为没有用户回复通常标志着互动结束。
智能体无缝地从中断处继续,理解为什么它们丢失了所有聊天记录。
这意味着什么
这种行为揭示了 LLM 处理上下文的一些基本原理。 上下文窗口不仅仅是记忆,它是实时塑造行为的主动训练数据。 当该上下文中的模式足够强大时,它们可以重写显式指令。
构建智能体或长期运行的 LLM 应用程序时:
- 注意你的模式:重复的消息结构会导致行为惯性。 如果你的智能体需要执行不同类型的任务,则应当确保消息模式多样化。
- 结构胜过指令:当你需要打破模式时,改变结构(如我们的 XML 包装)比添加指令更有效。
- 自一致性非常强大:LLM 与其先前的陈述保持着很强的一致性,这可以用来保持跨上下文边界的连续性。
修正现已成为 Koog 压缩系统的一部分。 调用 RetrieveFactsFromHistory(...)
时,它会自动处理,打破模式、保留上下文并保持连续性。 你不需要考虑任何事 – 只需要提供概念。
如果你想了解实现细节,可以查看公开的拉取请求。
Denis Domanskii 是 JetBrains 的 AI 智能体工程师。 他不是在教 LLM 忘记旧的习惯,就是在创造新的习惯。
由 Anthropic 的 Claude Opus 4 共同撰写,它花了 100 条消息来帮助撰写这篇文章,而没有一次尝试调用不存在的工具。 有进步! – Claude
本博文英文原作者: