跳到主要内容

本地 Agent 工具循环

这章讲什么: 上一章把消息送到了「Agent 分支」。本章讲 AstrBot 的核心价值——本地 Agent 怎么工作。它不是「问一次答一次」,而是一个循环:LLM 说要调工具,系统就去调,把结果喂回去,LLM 继续思考,如此往复直到它给出最终答案。这就是 ReAct 式 Agent,项目名里「Agent」的所在。

1. 它要解决的小问题

纯聊天模型只会「根据已知信息生成文字」。但很多请求需要外部行动:查实时天气、搜网页、读知识库、执行代码。模型本身做不到这些,它只能「说」它想做什么。

Agent 要解决的就是:让模型「说」的动作真的被执行,并把执行结果还给它继续推理。 一次用户提问可能需要好几轮这样的「思考-行动-观察」才能完成。

2. 思路/直觉:一个 step 是一次 LLM 调用,循环跑 step

核心抽象是 ToolLoopAgentRunner(工具循环 Agent 运行器)。它有一个 step()——跑一次 LLM 调用并处理结果;外层有一个循环反复跑 step() 直到 done()

用户输入 + 系统提示 + 工具列表


┌──────────────┐
│ step(): │◄─────────────────┐
│ 调 LLM │ │
└──────┬───────┘ │
▼ │
LLM 想调工具吗? │
├── 否 ──► 写最终回复, done() │
│ │
└── 是 ──► 执行工具 ──► 结果喂回上下文 ┘
(再跑下一个 step)

怎么读: 这是个循环,右边的箭头表示「调完工具后回到 step 再问一次 LLM」。只有当 LLM 不再要求调工具,循环才停。

AgentRunner 其实就是 ToolLoopAgentRunner 的类型别名,见 astrbot/core/astr_agent_run_util.py:24

3. 从流水线到 Agent:构建主 Agent

进入 Agent 分支后,InternalAgentSubStage.process(astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:162)做了几件事:

  1. 拿一个会话锁,保证同一会话串行处理(internal.py:216,session_lock_manager.acquire_lock)。
  2. build_main_agent 把这次请求装配成一个可运行的 Agent(internal.py:227)。
  3. 根据是否流式,调 run_agent(internal.py:343/:373)驱动循环。
  4. 循环结束后把对话历史存库(internal.py:402,_save_to_history)。

build_main_agent(astrbot/core/astr_main_agent.py:1328)是个「大装配车间」,它把一次裸请求逐层加料,最后 agent_runner.reset(...)(astr_main_agent.py:1608)。它注入的东西包括:

加什么在哪
人格(persona)+ 技能(skills)astr_main_agent.py:458 _ensure_persona_and_skills
知识库(注入结果或注入查询工具)astr_main_agent.py:267 _apply_kb
网页搜索工具astr_main_agent.py:1184 _apply_web_search_tools
引用消息、图片描述、附件astr_main_agent.py:800 _process_quote_message
沙箱/本地环境工具astr_main_agent.py:1078 _apply_sandbox_tools / :398 _apply_local_env_tools
系统提醒(用户 ID、群名、时间)astr_main_agent.py:901 _append_system_reminders

这就是「让 Agent 聪明」的地方: 同一个工具循环引擎,通过往请求里塞不同的系统提示和工具,就能变成「会搜网页的助手」「能跑代码的沙箱 Agent」「带人格的角色扮演」。

4. 核心机制:一个 step 的内部

step()(astrbot/core/agent/runners/tool_loop_agent_runner.py:693)是整个项目最核心的方法。拆开看:

① 调 LLM 前先处理上下文。request_context_manager.process(...)(tool_loop_agent_runner.py:713)做截断/压缩(详见 §05)。

② 流式拉取 LLM 响应。 _iter_llm_responses_with_fallback()(:718)逐块拉取,文本块和推理块即时 yield 给上层做流式输出(:724-:743);拿到最终非 chunk 响应后 break(:760)。

③ 判断 LLM 是否要调工具。

# 示意,非源码 —— step 的决策核心
if not llm_resp.tools_call_name:
await self._complete_with_assistant_response(llm_resp) # 无工具调用 = 给出最终答案
return

# 有工具调用:执行它们
async for result in self._handle_function_tools(self.req, llm_resp):
yield AgentResponse(type=result.kind, ...) # 把工具调用/结果报告给上层
# 把「assistant 的工具调用」和「工具返回结果」都追加进上下文
self.run_context.messages.extend(tool_calls_result.to_openai_messages_model())

真实逻辑:无工具调用走 _complete_with_assistant_response(:794,设 DONE 状态);有工具调用走 _handle_function_tools(:860),然后把结果拼成 ToolCallsResult 追加进消息历史(:898-:908)。注意:这里 step 结束时并不循环,循环在外层。

④ 外层循环。 run_agent(astr_agent_run_util.py:116)是真正的循环体:while step_idx < max_step + 1,每轮调 agent_runner.step(),直到 agent_runner.done()break(astr_agent_run_util.py:292)。它还负责把 step 吐出的事件转成发给用户的消息(工具状态、流式增量、最终文本)。

5. 巧妙之处一:工具结果是怎么「喂回」LLM 的

关键在 _handle_function_tools(tool_loop_agent_runner.py:978)。它对 LLM 给出的每个工具调用:

  1. 找到对应的 FunctionTool,通过 tool_executor 执行(下章细讲)。
  2. 把返回值包成一个 role="tool" 的消息段(ToolCallMessageSegment)。
  3. 大结果会溢出到文件:超过约 27500 estimated tokens 的工具输出,写进临时文件,只在上下文里放一段预览 + 一句「太大了,用 read 工具看 路径」(_materialize_large_tool_result,:393;阈值常量 TOOL_RESULT_MAX_ESTIMATED_TOKENS,:110)。这避免一个超长工具输出撑爆上下文。

下一轮 step 时,这些 role="tool" 消息已经在 run_context.messages 里,LLM 就「看到」了工具结果。

6. 巧妙之处二:工具 schema 双模式(省 token)

问题:工具很多、描述很长时,每次请求都把全部工具的完整 JSON Schema 发给 LLM,很费 token。

AstrBot 的解法是 tool_schema_mode(见 #4681):

模式怎么做
full(默认)直接把完整工具 schema 给 LLM
skills_like两段式:先给「轻量 schema」(只有名字+描述,无参数)让 LLM 决定调哪个;LLM 选定后,再用「仅参数 schema」二次查询拿到具体参数

实现:reset() 里若是 skills_like,把 req.func_tool 换成 get_light_tool_set()(tool_loop_agent_runner.py:295-:303)。step 里发现 LLM 选了工具,就调 _resolve_tool_exec 用 param-only schema 二次查询(:821-:855)。二次查询若没返回工具调用,有「强化指令重试」机制(:1324 附近,SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION)。

7. 巧妙之处三:重复工具调用的「三级递进劝退」

Agent 有时会卡在「反复调同一个工具」的死循环里。AstrBot 监测连续调用同一工具的次数(_same_tool_streak,tool_loop_agent_runner.py:283),达到阈值就往工具结果里注入语气递进的系统提示:

连续次数提示语气(模板)
3 次「顺便提醒,你已连续 3 次调用 X,要不要换个工具/参数?」(REPEATED_TOOL_NOTICE_L1_TEMPLATE)
4 次「重要:连续 4 次了,除非确有必要,请停止重复。」(L2)
5 次「重复已非常高,除非每次都产生新信息,否则换策略。」(L3)

阈值常量见 tool_loop_agent_runner.py:145-:165。这是个很实用的「软纠偏」——不强制打断,而是用提示引导模型自己跳出来。

8. 其他关键细节

  • max_step 兜底: 循环跑到 max_step + 1 那一轮,会拔掉所有工具并注入「工具次数已达上限,请直接总结回复」的提示,强制收尾(astr_agent_run_util.py:137-:151)。
  • 用户中断: 用户可中途叫停。_watch_agent_stop_signal(astr_agent_run_util.py:343)每 0.5s 检查停止信号;step 里若检测到中断,走 _finalize_aborted_step,保留已生成的部分输出(USER_INTERRUPTION_MESSAGE,tool_loop_agent_runner.py:115)。
  • 追问插队(follow-up): 工具执行期间用户又发了消息,会被捕获并作为系统提示插入下一步,提醒 Agent 优先处理(FOLLOW_UP_NOTICE_TEMPLATE,:119)。
  • 多模态回灌: 工具返回的图片,若模型支持图片输入,会作为 user 消息中的 image 追加进上下文,让 LLM「看到」工具产出的图(tool_loop_agent_runner.py:912-:945)。
  • Provider fallback: 主模型失败时按 fallback_providers 列表切换(_iter_llm_responses_with_fallback),配 tenacity 重试(resetrequest_max_retries)。

9. Live 模式(语音直播)

run_live_agent(astr_agent_run_util.py:351)是工具循环的一个变体:它在 Agent 产出文本的同时,边分句边喂给 TTS 生成音频流。用三个 asyncio 队列流水线化:Agent feeder 把文本分句进 text_queue,TTS 任务转成音频进 audio_queue,主循环再 yield 出去(:402-:461)。分句逻辑按标点切(:532)。

10. 边界与局限

  • 工具循环是串行的:一个 step 只处理一轮 LLM 调用,工具在该轮内执行;没有「多个工具并行跑」的全局并发编排(单轮内多个工具调用的执行细节见下章)。
  • max_step 默认 30(internal.py:62),复杂任务可能不够,但调大有 token 成本。
  • 上下文压缩是启发式的(按轮次/token),可能丢失对当前任务关键的早期信息。

11. 横向对比

AstrBot 的工具循环是教科书式的 ReAct loop,和「编码 Agent」类兄弟项目(如 aider、OpenHands)的循环骨架同源——都是「LLM 决定动作 → 执行 → 观察 → 重复」。差异在收尾和纠偏的工程化:AstrBot 把「重复调用劝退」「大结果溢出文件」「追问插队」「用户中断保留部分输出」这些聊天场景特有的体验细节做得很重,因为它的用户是 IM 里的真人,容错和响应感比纯自动化任务更重要。

12. 代码地图(导航索引)

主题文件路径符号名
工具循环运行器(核心)astrbot/core/agent/runners/tool_loop_agent_runner.pyToolLoopAgentRunner.step_handle_function_toolsreset
外层循环驱动astrbot/core/astr_agent_run_util.pyrun_agentrun_live_agent_watch_agent_stop_signal
Agent 别名astrbot/core/astr_agent_run_util.pyAgentRunner(= ToolLoopAgentRunner)
主 Agent 装配astrbot/core/astr_main_agent.pybuild_main_agent_ensure_persona_and_skills_decorate_llm_request
流水线 Agent 子阶段astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.pyInternalAgentSubStage.process_save_to_history
运行器基类 / 状态机astrbot/core/agent/runners/base.pyBaseAgentRunnerAgentState
重复调用阈值/模板astrbot/core/agent/runners/tool_loop_agent_runner.pyREPEATED_TOOL_NOTICE_L1_THRESHOLDMAX_STEPS_REACHED_PROMPT