Skip to content

[Bug]上下文管理截断时未保持 tool_calls 与 tool 消息的配对完整性,导致 400 报错 #7225

@CompilError-bts

Description

@CompilError-bts

What happened / 发生了什么

问题描述

在使用 Agent 功能(function calling / tool use)时,当对话历史较长触发上下文截断后,会稳定复现以下错误:

LLM 响应错误: All chat models failed: BadRequestError: Error code: 400 - {
  'error': {
    'message': "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'",
    'type': 'invalid_request_error',
    'param': None,
    'code': 'invalid_request_error'
  }
}

原因分析

OpenAI 及其兼容 API 要求对话消息中 tool 角色的消息必须紧跟在一条包含 tool_callsassistant 消息之后,即:

assistant (含 tool_calls) → tool (工具返回结果)

当 AstrBot 的上下文管理模块对历史消息进行截断时,如果只保留了 tool 消息而丢弃了前面 assistant 消息中的 tool_calls 字段,或者将这对消息拆散,就会产生孤立的 tool 消息,导致 API 返回 400 错误。

该问题在以下场景中容易触发:

  • 对话历史较长,上下文接近模型 token 上限需要截断时
  • 进行多步工具调用(Agent 多轮推理)后,历史消息累积较多时

Reproduce / 如何复现?

复现步骤

  1. 配置一个支持 function calling 的模型(如 OpenAI gpt-4o / gpt-4o-mini,或兼容接口)
  2. 启用 Agent 插件(如网页搜索、文件操作等工具)
  3. 发起一个需要多步工具调用的复杂任务(例如:搜索 → 读取内容 → 生成摘要)
  4. 在多轮工具调用完成后,继续发送消息,使对话历史累积到触发上下文截断的阈值
  5. 观察到 400 报错

期望行为

上下文截断时,应保证 tool_callstool 消息的原子性

  • 要么同时保留一对完整的 [assistant(tool_calls), tool] 消息
  • 要么同时移除这对消息
  • 不应出现只保留其一的情况

AstrBot version, deployment method (e.g., Windows Docker Desktop deployment), provider used, and messaging platform used. / AstrBot 版本、部署方式(如 Windows Docker Desktop 部署)、使用的提供商、使用的消息平台适配器

环境

  • AstrBot 版本:4.22.2
  • 模型提供者:zhipu
  • 模型名称:glm-5-turbo
  • 部署方式:local

可能的修复方向

建议在上下文截断逻辑中,对消息列表进行预处理:

  1. 回溯清理:从最早的消息开始,检测是否为 tool 角色,若是,则向前查找对应的 assistant(tool_calls) 消息,若不存在则移除该 tool 消息。
  2. 配对截断:以 [user, assistant, tool*] 对话轮次为最小截断单元,而非逐条消息截断。
  3. 工具调用链标记:在存储历史消息时,为工具调用链添加元数据标记,截断时自动识别并整体移除。

临时规避方案

  • 增大上下文轮数上限,减少截断频率
  • 每次复杂 Agent 任务开新会话
  • 报错后清除当前会话上下文重新开始

OS

Windows

Logs / 报错日志

openai.BadRequestError: Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}
[14:03:20.046] [Core] [WARN] [v4.22.2] [runners.tool_loop_agent_runner:287]: Switched from zhipu/glm-5-turbo to fallback chat provider: deepseek/deepseek-chat
[14:03:20.869] [Core] [WARN] [v4.22.2] [sources.openai_source:893]: 上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: 247
[14:03:21.094] [Core] [WARN] [v4.22.2] [runners.tool_loop_agent_runner:350]: Chat Model deepseek/deepseek-chat request error: Error code: 400 - {'error': {'message': "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}
Traceback (most recent call last):

File "", line 198, in _run_module_as_main
File "", line 88, in _run_code

File "C:\Users\BaiTS.local\bin\astrbot.exe_main_.py", line 10, in

File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\click\core.py", line 1485, in call
return self.main(*args, **kwargs)
│ │ │ └ {}
│ │ └ ()
│ └ <function Command.main at 0x000001DD002096C0>

File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\click\core.py", line 1406, in main
rv = self.invoke(ctx)
│ │ └ <click.core.Context object at 0x000001DD7A7D9610>
│ └ <function Group.invoke at 0x000001DD0020A5C0>

File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\click\core.py", line 1873, in invoke
return _process_result(sub_ctx.command.invoke(sub_ctx))
│ │ │ │ └ <click.core.Context object at 0x000001DD0031F110>
│ │ │ └ <function Command.invoke at 0x000001DD002093A0>
│ │ └
│ └ <click.core.Context object at 0x000001DD0031F110>
└ <function Group.invoke.._process_result at 0x000001DD7A81E160>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\click\core.py", line 1269, in invoke
return ctx.invoke(self.callback, **ctx.params)
│ │ │ │ │ └ {'port': None, 'reload': False}
│ │ │ │ └ <click.core.Context object at 0x000001DD0031F110>
│ │ │ └ <function run at 0x000001DD003BB380>
│ │ └
│ └ <function Context.invoke at 0x000001DD002085E0>
└ <click.core.Context object at 0x000001DD0031F110>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\click\core.py", line 824, in invoke
return callback(*args, **kwargs)
│ │ └ {'port': None, 'reload': False}
│ └ ()
└ <function run at 0x000001DD003BB380>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\cli\commands\cmd_run.py", line 56, in run
asyncio.run(run_astrbot(astrbot_root))
│ │ │ └ WindowsPath('C:/astrbot')
│ │ └ <function run_astrbot at 0x000001DD003BAD40>
│ └ <function run at 0x000001DD7A500680>
└ <module 'asyncio' from 'C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\init.py'>
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\runners.py", line 194, in run
return runner.run(main)
│ │ └ <coroutine object run_astrbot at 0x000001DD003D8150>
│ └ <function Runner.run at 0x000001DD7CE82980>
└ <asyncio.runners.Runner object at 0x000001DD003C4B90>
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\runners.py", line 118, in run
return self._loop.run_until_complete(task)
│ │ │ └ <Task pending name='Task-1' coro=<run_astrbot() running at C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages...
│ │ └ <function BaseEventLoop.run_until_complete at 0x000001DD7CE80540>
│ └
└ <asyncio.runners.Runner object at 0x000001DD003C4B90>
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\base_events.py", line 674, in run_until_complete
self.run_forever()
│ └ <function ProactorEventLoop.run_forever at 0x000001DD7CEF58A0>

File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\windows_events.py", line 322, in run_forever
super().run_forever()
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\base_events.py", line 641, in run_forever
self._run_once()
│ └ <function BaseEventLoop._run_once at 0x000001DD7CE822A0>

File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\base_events.py", line 1986, in _run_once
handle._run()
│ └ <function Handle._run at 0x000001DD7CE0E520>
└ <Handle <_asyncio.TaskStepMethWrapper object at 0x000001DDA1101480>()>
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\asyncio\events.py", line 88, in _run
self._context.run(self._callback, *self._args)
│ │ │ │ │ └ <member '_args' of 'Handle' objects>
│ │ │ │ └ <Handle <_asyncio.TaskStepMethWrapper object at 0x000001DDA1101480>()>
│ │ │ └ <member '_callback' of 'Handle' objects>
│ │ └ <Handle <_asyncio.TaskStepMethWrapper object at 0x000001DDA1101480>()>
│ └ <member '_context' of 'Handle' objects>
└ <Handle <_asyncio.TaskStepMethWrapper object at 0x000001DDA1101480>()>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\pipeline\scheduler.py", line 87, in execute
await self._process_stages(event)
│ │ └ <astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event.AiocqhttpMessageEvent object at 0x000001DDA2E1BDD0>
│ └ <function PipelineScheduler._process_stages at 0x000001DD74556980>
└ <astrbot.core.pipeline.scheduler.PipelineScheduler object at 0x000001DD9FCC0320>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\pipeline\scheduler.py", line 52, in _process_stages
async for _ in coroutine:
│ └ <async_generator object ProcessStage.process at 0x000001DD7E307B40>
└ None
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\pipeline\process_stage\stage.py", line 65, in process
async for _ in self.agent_sub_stage.process(event):
│ │ │ │ └ <astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event.AiocqhttpMessageEvent object at 0x000001DDA2E1BDD0>
│ │ │ └ <function AgentRequestSubStage.process at 0x000001DD77541300>
│ │ └ <astrbot.core.pipeline.process_stage.method.agent_request.AgentRequestSubStage object at 0x000001DDA0FC1C10>
│ └ <astrbot.core.pipeline.process_stage.stage.ProcessStage object at 0x000001DDA0FC0350>
└ None
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\pipeline\process_stage\method\agent_request.py", line 47, in process
async for resp in self.agent_sub_stage.process(event, self.prov_wake_prefix):
│ │ │ │ │ │ └ ''
│ │ │ │ │ └ <astrbot.core.pipeline.process_stage.method.agent_request.AgentRequestSubStage object at 0x000001DDA0FC1C10>
│ │ │ │ └ <astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event.AiocqhttpMessageEvent object at 0x000001DDA2E1BDD0>
│ │ │ └ <function InternalAgentSubStage.process at 0x000001DD76FFF740>
│ │ └ <astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal.InternalAgentSubStage object at 0x000001DDA0FC2480>
│ └ <astrbot.core.pipeline.process_stage.method.agent_request.AgentRequestSubStage object at 0x000001DDA0FC1C10>
└ None
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\pipeline\process_stage\method\agent_sub_stages\internal.py", line 335, in process
async for _ in run_agent(
│ └ <function run_agent at 0x000001DD76F5D080>
└ None
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\astr_agent_run_util.py", line 124, in run_agent
async for resp in agent_runner.step():
│ │ └ <function ToolLoopAgentRunner.step at 0x000001DD0408AAC0>
│ └ <astrbot.core.agent.runners.tool_loop_agent_runner.ToolLoopAgentRunner object at 0x000001DDA2EC2090>
└ AgentResponse(type='tool_call_result', data={'chain': MessageChain(chain=[Json(type=<ComponentType.Json: 'Json'>, data={'id':...
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\agent\runners\tool_loop_agent_runner.py", line 456, in step
async for llm_response in self._iter_llm_responses_with_fallback():
│ └ <function ToolLoopAgentRunner._iter_llm_responses_with_fallback at 0x000001DD0408A700>
└ <astrbot.core.agent.runners.tool_loop_agent_runner.ToolLoopAgentRunner object at 0x000001DDA2EC2090>

File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\agent\runners\tool_loop_agent_runner.py", line 305, in iter_llm_responses_with_fallback
async for attempt in retrying:
│ └ <AsyncRetrying object at 0x1dda10afad0 (stop=<tenacity.stop.stop_after_attempt object at 0x000001DD76EACDA0>, wait=<tenacity....
└ <tenacity.AttemptManager object at 0x000001DDA2E0DFD0>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\tenacity\asyncio_init
.py", line 170, in anext
do = await self.iter(retry_state=self.retry_state)
│ │ │ └ <RetryCallState 2051401249520: attempt #1; slept for 0.0; last result: failed (BadRequestError Error code: 400 - {'error': {'...
│ │ └ <AsyncRetrying object at 0x1dda10afad0 (stop=<tenacity.stop.stop_after_attempt object at 0x000001DD76EACDA0>, wait=<tenacity....
│ └ <function AsyncRetrying.iter at 0x000001DD03676480>
└ <AsyncRetrying object at 0x1dda10afad0 (stop=<tenacity.stop.stop_after_attempt object at 0x000001DD76EACDA0>, wait=<tenacity....
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\tenacity\asyncio_init
.py", line 157, in iter
result = await action(retry_state)
│ └ <RetryCallState 2051401249520: attempt #1; slept for 0.0; last result: failed (BadRequestError Error code: 400 - {'error': {'...
└ <function wrap_to_async_func..inner at 0x000001DDA0F782C0>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\tenacity_utils.py", line 111, in inner
return call(*args, **kwargs)
│ │ └ {}
│ └ (<RetryCallState 2051401249520: attempt #1; slept for 0.0; last result: failed (BadRequestError Error code: 400 - {'error': {...
└ <function BaseRetrying.post_retry_check_actions.. at 0x000001DDA0F799E0>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\tenacity_init
.py", line 393, in
self._add_action_func(lambda rs: rs.outcome.result())
│ │ │ └ <function Future.result at 0x000001DD7CD84680>
│ │ └ <Future at 0x1dda2d44650 state=finished raised BadRequestError>
│ └ <RetryCallState 2051401249520: attempt #1; slept for 0.0; last result: failed (BadRequestError Error code: 400 - {'error': {'...
└ <RetryCallState 2051401249520: attempt #1; slept for 0.0; last result: failed (BadRequestError Error code: 400 - {'error': {'...
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\concurrent\futures_base.py", line 449, in result
return self.__get_result()
└ None
File "C:\Users\BaiTS\AppData\Local\Programs\Python\Python312\Lib\concurrent\futures_base.py", line 401, in __get_result
raise self._exception
└ None
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\agent\runners\tool_loop_agent_runner.py", line 309, in _iter_llm_responses_with_fallback
async for resp in self._iter_llm_responses(
│ └ <function ToolLoopAgentRunner._iter_llm_responses at 0x000001DD0408A660>
└ <astrbot.core.agent.runners.tool_loop_agent_runner.ToolLoopAgentRunner object at 0x000001DDA2EC2090>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\agent\runners\tool_loop_agent_runner.py", line 272, in _iter_llm_responses
yield await self.provider.text_chat(**payload)
│ │ │ └ {'contexts': [Message(role='system', content='You are running in Safe Mode.\n\nRules:\n- Do NOT generate pornographic, sexual...
│ │ └ <function ProviderOpenAIOfficial.text_chat at 0x000001DD7693AF20>
│ └ <astrbot.core.provider.sources.openai_source.ProviderOpenAIOfficial object at 0x000001DD00A6EC00>
└ <astrbot.core.agent.runners.tool_loop_agent_runner.ToolLoopAgentRunner object at 0x000001DDA2EC2090>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\provider\sources\openai_source.py", line 1025, in text_chat
) = await self._handle_api_error(
│ └ <function ProviderOpenAIOfficial._handle_api_error at 0x000001DD7693AE80>
└ <astrbot.core.provider.sources.openai_source.ProviderOpenAIOfficial object at 0x000001DD00A6EC00>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\provider\sources\openai_source.py", line 973, in _handle_api_error
raise e
└ BadRequestError('Error code: 400 - {'error': {'message': "Messages with role 'tool' must be a response to a preceding m...
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\provider\sources\openai_source.py", line 1013, in text_chat
llm_response = await self._query(payloads, func_tool)
│ │ │ └ ToolSet(tools=[BgmAdvancedSubjectSearchTool(name='bgm_search_subjects_advanced', description='当用户要在 ACG 领域中按关键词+筛选条件检索时调用。适用于...
│ │ └ {'messages': [{'role': 'system', 'content': 'You are running in Safe Mode.\n\nRules:\n- Do NOT generate pornographic, sexuall...
│ └ <function ProviderOpenAIOfficial._query at 0x000001DD7693A980>
└ <astrbot.core.provider.sources.openai_source.ProviderOpenAIOfficial object at 0x000001DD00A6EC00>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\astrbot\core\provider\sources\openai_source.py", line 460, in _query
completion = await self.client.chat.completions.create(
│ │ │ │ └ <function AsyncCompletions.create at 0x000001DD76AD11C0>
│ │ │ └ <openai.resources.chat.completions.completions.AsyncCompletions object at 0x000001DD768AE0F0>
│ │ └ <openai.resources.chat.chat.AsyncChat object at 0x000001DD768AF1A0>
│ └ <openai.AsyncOpenAI object at 0x000001DD00A6FBF0>
└ <astrbot.core.provider.sources.openai_source.ProviderOpenAIOfficial object at 0x000001DD00A6EC00>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\openai\resources\chat\completions\completions.py", line 2714, in create
return await self.post(
│ └ <bound method AsyncAPIClient.post of <openai.AsyncOpenAI object at 0x000001DD00A6FBF0>>
└ <openai.resources.chat.completions.completions.AsyncCompletions object at 0x000001DD768AE0F0>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\openai_base_client.py", line 1884, in post
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
│ │ │ │ │ └ openai.AsyncStream[openai.types.chat.chat_completion_chunk.ChatCompletionChunk]
│ │ │ │ └ False
│ │ │ └ FinalRequestOptions(method='post', url='/chat/completions', params={}, headers=NOT_GIVEN, max_retries=NOT_GIVEN, timeout=NOT
...
│ │ └ <class 'openai.types.chat.chat_completion.ChatCompletion'>
│ └ <function AsyncAPIClient.request at 0x000001DD03EB1260>
└ <openai.AsyncOpenAI object at 0x000001DD00A6FBF0>
File "C:\Users\BaiTS\AppData\Roaming\uv\tools\astrbot\Lib\site-packages\openai_base_client.py", line 1669, in request
raise self._make_status_error_from_response(err.response) from None
│ └ <function BaseClient._make_status_error_from_response at 0x000001DD03EA65C0>
└ <openai.AsyncOpenAI object at 0x000001DD00A6FBF0>

openai.BadRequestError: Error code: 400 - {'error': {'message': "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}
[14:03:21.203] [Core] [INFO] [respond.stage:184]: Prepare to send - Confident symbol/952032432: LLM 响应错误: All chat models failed: BadRequestError: Error code: 400 - {'error': {'message': "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}

Are you willing to submit a PR? / 你愿意提交 PR 吗?

  • Yes!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:coreThe bug / feature is about astrbot's core, backendbugSomething isn't workingpriority: p0

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions