跳到主要内容

本地 Agent 循环:工具调用、流式、模型回退

30 秒导读: 流水线跑到最后一段,需要真正"想一句话回复"。这件事交给一个叫 Runner(请求运行器) 的可插拔组件——它就是流水线的"大脑"。默认的 LocalAgentRunner 在本地跑一个 agentic 工具循环:调大模型 → 模型说"我要调工具" → 执行工具 → 把工具结果塞回对话 → 再调大模型,如此往复,直到模型不再要求工具、给出最终答复。本章拆开这颗大脑:它怎么被选中、怎么流式吐字、怎么在主模型挂掉时换回退模型、怎么用一个硬上限防止工具循环失控。

本章聚焦"大脑"本身。工具从哪来(四来源聚合)见 04;沙箱与插件进程的实现05;这一段处理器上游是怎样被生成器分叉驱动的,见 02


1. 这是什么(零基础也能懂)

一句话定义: 流水线的最后一个业务处理器 ChatMessageHandler 负责"生成回复",但它自己不写生成逻辑,而是选一个 Runner 来干。Runner 是"大脑"的抽象——同一条流水线,配 local-agent 就本地跑 LLM+工具,配 dify-service-api 就把整个对话甩给外部 Dify 工作流。

解决什么问题: 一个聊天机器人平台不该把"怎么想"焊死。有人要本地模型 + 自己的工具,有人要接 Dify / Coze / n8n 这类现成工作流。LangBot 把"想"这一步抽成一个接口,让你在流水线配置里换一个字符串就换一颗大脑。

核心的那颗大脑长什么样: 默认的 local-agent。它做的是教科书式的 ReAct / tool-use 循环

用户:"北京现在几点,帮我算下距离下班还有多久"
→ 模型:(我需要查时间)调用 get_time 工具
→ 系统:执行 get_time,得到 "18:42"
→ 把 "18:42" 作为 tool 消息塞回对话
→ 再问模型:模型这次直接回答 "现在 18:42,距 19:00 下班还有 18 分钟"

一句话直觉: 把 Runner 想成一个可换的引擎ChatMessageHandler 是发动机舱。本地引擎(localagent)自己烧油(本地 LLM)、自己带机械臂(工具循环);外部引擎(dify/coze/…)只是一根接到别人发动机的传动轴。


2. 顶层全景(它大概怎么转)

2.1 两层结构:处理器 挑 Runner,Runner 干活

src/langbot/pkg/pipeline/process/handlers/chat.py
┌─────────────────────── ChatMessageHandler.handle ───────────────────────┐
│ 1. 触发插件事件 (NormalMessageReceived) │
│ ├─ 插件 prevent_default → 直接用插件的回复,短路 │
│ └─ 插件 user_message_alter → 改写用户消息后继续 │
│ 2. 按 config['ai']['runner']['runner'] 从 preregistered_runners 选 Runner│
│ 3. async for result in runner.run(query): ← 逐条消费 Runner 生成器 │
│ ├─ 流式:首条到达才 create_message_card,之后每条替换卡片内容 │
│ └─ 非流式:逐条 append 到 resp_messages │
│ 每条都 yield StageProcessResult(CONTINUE) → 驱动上游生成器分叉 │
│ 4. finally: telemetry 收尾(时长/模型/工具轮数/RAG…) │
└──────────────────────────────────────────────────────────────────────────┘
│ runner.run(query)

┌──────────────── 可插拔的"大脑"(runners/ 目录,各一 Runner)────────────┐
│ local-agent ← 本章主角:本地 LLM + 工具循环 + 流式 + 回退 │
│ dify / coze / n8n / langflow / deerflow / weknora / dashscope / tbox │
│ ← 各自把对话代理到一个外部工作流服务 │
└──────────────────────────────────────────────────────────────────────────┘

怎么读这张图: 从上到下是一次调用的纵深。上半框是"选大脑 + 消费大脑吐出的消息",下半框是"大脑本身"。本章重点解剖下半框里的 local-agent

2.2 部件一句话职责

部件干什么在哪
ChatMessageHandler.handle触发插件事件、选 Runner、消费其生成器、telemetry 收尾src/langbot/pkg/pipeline/process/handlers/chat.py:28
runner_class 装饰器把一个 Runner 类按名字注册进全局表src/langbot/pkg/provider/runner.py:16
preregistered_runners所有已注册 Runner 的清单,按 name 匹配选中src/langbot/pkg/provider/runner.py:13
RequestRunner大脑的抽象基类,只有一个 async def runsrc/langbot/pkg/provider/runner.py:27
LocalAgentRunner本地大脑:工具循环 + 流式累积 + 模型回退src/langbot/pkg/provider/runners/localagent.py:104
runners/*api.py8 个外部工作流各一个 Runner(dify/coze/n8n/…)src/langbot/pkg/provider/runners/

2.3 主线走一遍(高层)

  1. 流水线把 query(带用户消息、prompt、可用工具、模型 UUID、pipeline_config)交给 ChatMessageHandler
  2. 处理器先给插件一个拦截/改写的机会,再按配置选中一个 Runner,async for 消费它的输出。
  3. LocalAgentRunner.run 是个异步生成器:它一边跑一边 yield 出"文本片段 / 一条完整回复 / 一条工具结果",处理器拿到就往前送。
  4. yield 一次,处理器就 yield 一个 StageProcessResult(CONTINUE)——这层层的生成器 yield 正是上游流水线引擎"生成器分叉"能实时把字推给用户的原因。

3. Runner 注册表:大脑怎么被选中

3.1 一个装饰器 + 一张全局清单

注册机制小得可以一眼看完。runner.py 全文只有一个装饰器和一个列表:

# 示意,接近源码 · src/langbot/pkg/provider/runner.py
preregistered_runners: list[type[RequestRunner]] = []

def runner_class(name: str): # 给类挂个名字并登记
def decorator(cls):
cls.name = name
preregistered_runners.append(cls)
return cls
return decorator

真实代码见 src/langbot/pkg/provider/runner.py:13preregistered_runners)与 :16runner_class)。任何 Runner 只要顶上写 @runner.runner_class('some-name'),模块被 import 时就自动进表。

import 的时机很关键: 处理器模块开头一句 importutil.import_modules_in_pkg(runners)src/langbot/pkg/pipeline/process/handlers/chat.py:24)把 runners/ 目录下每个文件都 import 一遍——装饰器随之执行,全表填满。这就是"往目录里丢一个文件就多一颗大脑"的插拔来源。

3.2 抽象基类:大脑的唯一约定

# src/langbot/pkg/provider/runner.py:27
class RequestRunner(abc.ABC):
name: str = None
@abc.abstractmethod
async def run(self, query) -> AsyncGenerator[Message | MessageChunk, None]:
...

约定只有一条:run 是异步生成器,产出 Message(整条)或 MessageChunk(流式分片)。返回什么、调不调工具、流不流式,全由各 Runner 自己决定。

3.3 处理器按名字选中

选中逻辑就是一次线性查表:

# 示意 · src/langbot/pkg/pipeline/process/handlers/chat.py:95
for r in runner_module.preregistered_runners:
if r.name == query.pipeline_config['ai']['runner']['runner']:
runner = r(self.ap, query.pipeline_config)
break
else:
raise ValueError(f'Request Runner not found: ...')

config['ai']['runner']['runner'] 是流水线里配的字符串(如 local-agent)。找不到就直接抛错。真实代码 src/langbot/pkg/pipeline/process/handlers/chat.py:95-100

3.4 已注册的九颗大脑

名字(config 值)性质
local-agentLocalAgentRunner本地:本地 LLM + 工具循环(本章主角)
dify-service-apiDifyServiceAPIRunner外部:代理到 Dify 工作流
coze-apiCozeAPIRunner外部:代理到 Coze
n8n-service-apiN8nServiceAPIRunner外部:代理到 n8n
langflow-apiLangflowAPIRunner外部:代理到 Langflow
deerflow-apiDeerFlowAPIRunner外部:代理到 DeerFlow
weknora-apiWeKnoraAPIRunner外部:代理到 WeKnora
dashscope-app-apiDashScopeAPIRunner外部:代理到阿里百炼 App
tbox-app-apiTboxAPIRunner外部:代理到 Tbox App

装饰器逐一见 src/langbot/pkg/provider/runners/*.py 各文件顶部(如 difysvapi.py:19cozeapi.py:15)。

本地 vs 外部的分野: 外部 Runner 通常在 __init__ 里就拿 config 建好一个 HTTP client(例 DifyServiceAPIRunner.__init__src/langbot/pkg/provider/runners/difysvapi.py:24),run 里只是把用户消息转成对方 API 的格式、把对方的流式响应转回 MessageChunk——它不碰 LangBot 的模型管理,也不跑工具循环。真正"自己想 + 自己动手"的只有 local-agent。下面全部聚焦它。


4. 核心:LocalAgentRunner.run 的工具循环

这是全章的心脏。它要解决的小问题:LLM 一次回复可能不是终点,而是"我要先调个工具"。谁来执行工具、把结果喂回去、再问一遍模型? 答案就是 run 里那个 while pending_tool_calls 循环。

4.1 思路:把"要工具"当成"还没说完"

一次模型调用返回后,回复里可能带 tool_calls(模型说"请帮我执行这些函数")。只要带着,就说明模型还没准备好给用户最终答复。于是:

┌──────────────────────────────────────────────┐
│ 调模型 → final_msg │
└───────────────┬──────────────────────────────┘

final_msg.tool_calls 非空?
┌─────┴─────┐
是 │ │ 否
▼ ▼
┌───────────────────────┐ ┌──────────────┐
│ 逐个执行工具 │ │ 结束,最终回复 │
│ execute_func_call │ └──────────────┘
│ 把每个结果作为 role=tool│
│ 消息 append 回 req │
│ 用【同一个】模型再调 │──┐
└───────────────────────┘ │ 回到顶部(下一轮)
▲ │
└────────────────┘
轮数 > MAX_TOOL_CALL_ROUNDS(128) → 强制 break

怎么读: 菱形是判据"模型还要不要工具"。要,就执行 + 回喂 + 再调,构成循环;不要,就跳出,最后一条即最终答复。

4.2 循环骨架(简化)

# 示意 · 对应 src/langbot/pkg/provider/runners/localagent.py:484 起
pending_tool_calls = final_msg.tool_calls
req_messages.append(final_msg) # 把模型的"我要调工具"也记进对话

tool_call_round = 0
while pending_tool_calls:
tool_call_round += 1
if tool_call_round > MAX_TOOL_CALL_ROUNDS: # 硬上限,见 4.5
break
for tool_call in pending_tool_calls:
args = json.loads(tool_call.function.arguments or '{}')
func_ret = await self.ap.tool_mgr.execute_func_call( # 真正执行
tool_call.function.name, args, query=query)
msg = Message(role='tool', content=..., tool_call_id=tool_call.id)
yield msg # 工具结果也吐给上游(可展示)
req_messages.append(msg) # 关键:塞回对话,喂给下一次模型调用
final_msg = await use_llm_model.provider.invoke_llm(query, use_llm_model, req_messages, ...)
yield final_msg
pending_tool_calls = final_msg.tool_calls # 模型这轮还要工具吗?
req_messages.append(final_msg)

三个动作构成一轮:执行工具 → 把 role='tool' 结果塞回 req_messages → 用同一个模型再调。真实代码:while pending_tool_callslocalagent.py:484execute_func_call 调用在 :502;把工具结果 req_messages.append(msg):530;非流式再调模型在 :573

工具的实际派发落在 src/langbot/pkg/provider/tools/toolmgr.py:103execute_func_call——它怎么在四来源里找到这个函数、怎么路由,属于 04,本章不展开。

4.3 "锁定模型":工具循环里不再回退

注意 4.2 里循环内用的是 use_llm_model(同一个),而不是重新走回退序列。源码在此处有明确注释:

# src/langbot/pkg/provider/runners/localagent.py:481-482
# Once a model succeeds, commit to it for the tool call loop
# (no fallback mid-conversation — different models may interpret tool results differently)

为什么: 回退只发生在"第一次调模型"阶段(见 §5)。一旦某个模型成功开口,整段工具循环就锁死在它身上。中途换模型会让"对同一批工具结果的理解"不一致,破坏对话连贯性。这是个刻意的取舍。

4.4 工具结果怎么塞回去(两种形态)

工具返回值分两类处理(localagent.py:506-513):

返回值形态处理
list[ContentElement](如工具返回图片等富内容)原样作为 content
其它(dict/str/数字…)json.dumps(..., ensure_ascii=False) 序列化成文本

执行出错也不会中断整轮:except 分支把 content=f'err: {e}' 作为一条 role='tool' 消息喂回去(localagent.py:531-544),让模型自己决定是重试、换法还是向用户道歉。

4.5 硬上限 MAX_TOOL_CALL_ROUNDS = 128

# src/langbot/pkg/provider/runners/localagent.py:42
MAX_TOOL_CALL_ROUNDS = 128

源码注释点破了它的必要性:一个循环的或对抗性的模型可以无限吐 tool_calls(每个可能还是一次沙箱执行),造成永不终止的请求和失控成本。128 设得足够大,正常多步 agent 工作流不会撞到;但只要越界就 breaklocalagent.py:487-492),把这一轮的模型输出当最终答复。每轮还会打点 telemetry tool_call_rounds:486)。


5. 主模型 + 回退模型序列

要解决的小问题:主模型可能限流、超时、挂掉。 别让一次网络抖动毁掉整条回复。

5.1 候选序列:主 + 回退

_get_model_candidates 组装一个有序候选列表:先主模型(query.use_llm_model_uuid),再依次追加 query.variables['_fallback_model_uuids'] 里的回退模型;任一 UUID 找不到就跳过并告警。

# 示意 · src/langbot/pkg/provider/runners/localagent.py:187 起
candidates = []
if query.use_llm_model_uuid:
candidates.append(await model_mgr.get_model_by_uuid(query.use_llm_model_uuid)) # 主
for fb_uuid in query.variables.get('_fallback_model_uuids', []):
candidates.append(await model_mgr.get_model_by_uuid(fb_uuid)) # 回退们

真实代码 src/langbot/pkg/provider/runners/localagent.py:187-211。候选为空直接抛 RuntimeError('No LLM model configured ...'):431)。

5.2 非流式回退:逐个试,成一个就返回

_invoke_with_fallback 顺着候选表挨个 invoke_llm,成功即返回 (msg, model);失败记下错误、告警、试下一个;全挂则抛最后一个错误。见 localagent.py:213-237

5.3 流式回退:只能在"第一片吐出前"换

流式棘手:一旦第一片字发给了用户,就不能中途换模型了(用户已经看到半句话)。_invoke_stream_with_fallback 的巧妙处是先偷看第一片来验活:

# 示意 · src/langbot/pkg/provider/runners/localagent.py:239 起
for model in candidates:
try:
stream = model.provider.invoke_llm_stream(...)
first_chunk = await stream.__anext__() # 先拿第一片,验证流可用
async def _chain_stream(first, rest): # 把偷看的第一片接回去
yield first
async for c in rest: yield c
return _chain_stream(first_chunk, stream), model
except StopAsyncIteration:
return _empty_stream(), model # 空流也算成功
except Exception:
continue # 建流/首片就失败 → 换下一个

关键在 first_chunk = await stream.__anext__()localagent.py:264):取到第一片才算这个模型活着。取第一片就抛异常 → 还没对用户吐任何字 → 可以安全回退。取到之后失败就只能认栽(模型已锁定)。这与 §4.3"锁定模型"是同一条纪律的两面。源码注释:Fallback is only possible before any chunks have been yielded to the client.:249)。

5.4 只对有 func_call 能力的模型传工具

每次调模型时都过一道 _model_has_ability(model, 'func_call'):模型声明支持函数调用才把 query.use_funcs 传进去,否则传空列表(localagent.py:229:259:561:577)。避免给不支持工具的模型硬塞工具定义。


6. 流式:_StreamAccumulator 怎么把碎片攒成话

要解决的小问题:LLM 流式返回的是一地碎片——文本一个 token 一个 token 地来,tool_calls 也被 OpenAI 式协议拆成"先给 id/name,再一段段追加 arguments 字符串"。谁把碎片拼回完整的一条?

6.1 累积器职责

_StreamAccumulatorlocalagent.py:49)维护三样东西:

  • accumulated_content:把每片 content 首尾相接。
  • tool_calls_map:按 tool_call.id 归并;同一个 id 的 arguments 分片不断 += 拼接(localagent.py:79-80)。
  • msg_sequence:给下游用的递增序号。

6.2 节流:每 8 片才吐一次

它不是每来一片就往外发,而是攒着,每 8 片(或收到 is_final)才产出一个"到目前为止的完整快照":

# 示意 · src/langbot/pkg/provider/runners/localagent.py:82
if self.msg_idx % 8 == 0 or msg.is_final:
self.msg_sequence += 1
return MessageChunk(role=..., content=self.accumulated_content,
tool_calls=... if msg.is_final else None,
is_final=msg.is_final, msg_sequence=self.msg_sequence)
return None # 其余时候攒着不发

为什么每 8 片: 每个快照带的是累积全文(不是增量),下游处理器每收一条就整卡片替换(见 §7)。8 片一发是"实时感"与"消息量/日志量"的折中。tool_calls 只在 is_final 那一发才附上——碎片没拼完之前给不出完整的函数参数(localagent.py:87)。

6.3 跨工具轮的序号连续

工具循环里每再调一次模型,都会新建一个累积器,但把上一段的结束序号和已累积内容接续进去(localagent.py:552-555msg_sequence=first_end_sequence, initial_content=first_content)。这样多轮工具调用吐出的分片,序号是一条连续的流,前端不会看到序号跳变或内容回退。


7. 上游怎么消费:流式卡片与生成器分叉

Runner 只管 yield;把这些 yield 变成用户屏幕上跳动的字,是 ChatMessageHandler 的活。

7.1 流式:首条到达才建卡片

# 示意 · src/langbot/pkg/pipeline/process/handlers/chat.py:108 起
async for result in runner.run(query):
result.resp_message_id = str(resp_message_id)
if query.resp_messages: query.resp_messages.pop() # 只保留最新一条
if query.resp_message_chain: query.resp_message_chain.pop()
if not is_create_card: # 第一条才创建卡片
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True
query.resp_messages.append(result)
yield StageProcessResult(result_type=ResultType.CONTINUE, new_query=query)

要点(chat.py:108-138):

  • 等第一条真到了才 create_message_card——这条注释写得很直白(chat.py:114:"此时连接外部 AI 服务正常,创建卡片"):只有确认模型确实开口,才在平台侧建一张"流式卡片",避免模型直接失败却留下一张空卡。
  • 每条新 resultpop 掉旧的再 append——resp_messages始终只放最新的累积快照,配合 §6.2 每片带全文,等价于"整卡片刷新"。
  • 每条都 yield StageProcessResult(CONTINUE)——这一层 yield 把控制权交回上游生成器,流水线引擎据此把这一片实时送达平台。这就是"生成器分叉":Runner 的生成器套在处理器的生成器里,处理器的又套在引擎里,字一路往外冒。

7.2 非流式:逐条 append

非流式简单得多(chat.py:146-156):async for 拿到每条完整 Messageappendresp_messagesyield 一个 CONTINUE。没有卡片、没有 pop 替换。

7.3 telemetry 收尾(finally)

不论成败,finally 块(chat.py:182-245)收集一次匿名遥测:时长 duration_ms、adapter 名、runner 名与类别(get_runner_category_from_runner 把 URL 判成 local/cloud)、local-agent 时的模型名、以及本次 query 采集到的特征(工具调用轮数、RAG 用量、沙箱执行等,telemetry_features.collect_features)。异步上报,且被 try 包住——遥测出错绝不影响正常回复chat.py:243)。


8. 循环开始之前:插件事件、RAG 注入、沙箱附件

run 真正进循环前有三段准备工作,值得单列。

8.1 处理器侧:插件的拦截与改写

在选 Runner 之前,handle 先触发 PersonNormalMessageReceived / GroupNormalMessageReceived 插件事件(chat.py:37-55),给插件两种介入:

插件动作效果源码
prevent_default() + 带 reply_message_chain用插件自备的回复短路,CONTINUE 出去,不调任何 Runnerchat.py:59-71
prevent_default() 但无回复INTERRUPT,静默中止chat.py:72-76
user_message_alter(str / list / ContentElement)改写用户消息内容后再进 Runnerchat.py:78-86

这让插件既能完全接管一次回复,也能只做"改写输入"的轻介入。插件运行时本身见 05

8.2 Runner 侧:RAG 上下文注入

若 query 带了知识库(_knowledge_base_uuids),run 会先对每个库 retrieve、可选 rerank 重排、取 top-k,然后用一个固定模板把检索片段包在用户消息前面:

# src/langbot/pkg/provider/runners/localagent.py:15
rag_combined_prompt_template = """
The following are relevant context entries retrieved from the knowledge base.
...
<context>
{rag_context}
</context>
<user_message>
{user_message}
</user_message>
"""

拼装发生在 localagent.py:397-418:把带编号 [1] [2]… 的检索文本填进 {rag_context}、原始问题填进 {user_message},再写回用户消息的 text 元素。检索/重排细节属于 RAG 子系统,这里只关心"它在进模型前改写了用户消息"。

8.3 Runner 侧:沙箱附件与 outbox 约定

_inject_inbound_attachmentslocalagent.py:107)在 box 服务可用时,把用户发来的图片/语音/文件落进沙箱,然后给模型追加一条系统说明:这些文件在沙箱里的路径是什么、以及"你要回给用户的文件,写进 OUTBOX_MOUNT_DIR/<query_id>/ 就会自动送达"(localagent.py:132-143)。

两个细节:

  • 非图片类附件(file_base64/file_url)的原始字节不会作为多模态内容发给聊天模型(供应商会拒),而是靠沙箱路径的文字说明让 agent 用 exec/read 工具去读;图片保留给视觉模型(localagent.py:146-157)。
  • 附件 IO 失败只告警、绝不打断对话(localagent.py:124-125)。

沙箱(Box)本身的实现见 05


9. 边界与局限(诚实)

  • 工具循环封顶 128 轮,超出直接把当轮输出当最终答复(localagent.py:487)——极端多步任务会被硬截断。
  • 流式一旦吐出第一片就锁定模型,之后模型/网络出问题无法回退,只能在流里体现为错误或截断(§5.3)。
  • 工具循环内完全不回退(§4.3):锁定的模型在某一轮挂掉,本轮就失败。
  • 回退只覆盖"调模型"这一步,不覆盖工具执行本身;工具报错是喂回模型让它自处理,而非切换基础设施。
  • RAG 仅支持文本用户消息:注入前提是能取到 user_message_textlocalagent.py:313if kb_uuids and user_message_text),纯图片/语音消息不会触发知识库检索。
  • 外部 Runner 各有各的约束(app-type 校验、鉴权、流式转换),本章未逐一展开;接入某个外部工作流前应读对应 runners/*api.py

10. 巧妙之处(可借鉴)

  • 大脑即插件、一行装饰器登记、一次目录 import 全装载runner_class + preregistered_runners + import_modules_in_pkg(runners),加一颗大脑 = 丢一个文件。src/langbot/pkg/provider/runner.py:16src/langbot/pkg/pipeline/process/handlers/chat.py:24
  • "偷看第一片"来实现安全流式回退:用 stream.__anext__() 验活、再用 _chain_stream 把偷看的片接回去,把"能不能回退"精确卡在"有没有对用户吐字"这条线上。src/langbot/pkg/provider/runners/localagent.py:264
  • 累积器发累积全文 + 每 8 片节流 + 跨轮序号连续:下游只需"整卡片替换",无需自己拼增量,多轮工具调用在用户眼里是一条连贯的流。src/langbot/pkg/provider/runners/localagent.py:82:552
  • 成功即锁定模型,用一句注释道破"不同模型解释工具结果不一致"的坑,把一致性置于韧性之上。src/langbot/pkg/provider/runners/localagent.py:481
  • 对抗性防护写进常量MAX_TOOL_CALL_ROUNDS 的注释直言防"非终止请求与失控成本",把安全考量落到一个显眼的数字上。src/langbot/pkg/provider/runners/localagent.py:42

11. 横向对比(同组其它章)


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

主题文件符号
处理器:选 Runner + 消费生成器 + telemetrysrc/langbot/pkg/pipeline/process/handlers/chat.pyChatMessageHandler.handle
插件事件拦截 / 改写src/langbot/pkg/pipeline/process/handlers/chat.pyis_prevented_default / user_message_alter
runners 目录一次性 importsrc/langbot/pkg/pipeline/process/handlers/chat.pyimport_modules_in_pkg(runners)
Runner 注册装饰器src/langbot/pkg/provider/runner.pyrunner_class
Runner 全局清单src/langbot/pkg/provider/runner.pypreregistered_runners
Runner 抽象基类src/langbot/pkg/provider/runner.pyRequestRunner
本地大脑主体src/langbot/pkg/provider/runners/localagent.pyLocalAgentRunner.run
工具轮次硬上限src/langbot/pkg/provider/runners/localagent.pyMAX_TOOL_CALL_ROUNDS
流式碎片累积器src/langbot/pkg/provider/runners/localagent.py_StreamAccumulator
候选模型序列(主+回退)src/langbot/pkg/provider/runners/localagent.py_get_model_candidates
非流式回退src/langbot/pkg/provider/runners/localagent.py_invoke_with_fallback
流式回退(偷看第一片)src/langbot/pkg/provider/runners/localagent.py_invoke_stream_with_fallback
沙箱附件注入src/langbot/pkg/provider/runners/localagent.py_inject_inbound_attachments
RAG 上下文模板src/langbot/pkg/provider/runners/localagent.pyrag_combined_prompt_template
工具实际派发src/langbot/pkg/provider/tools/toolmgr.pyexecute_func_call
外部 Runner 样例src/langbot/pkg/provider/runners/difysvapi.pyDifyServiceAPIRunner

说明:本仓库实际源码根为 src/langbot/,上表路径均以 src/langbot/pkg/... 为准(as-of commit ebab5343)。符号名用于 grep 定位,比行号抗漂移。