跳到主要内容

可插拔推理:ReAct、ReWOO 与插件式架构

30 秒导读: ktem(kotaemon 的应用层)把"怎么回答用户"这件事抽象成一个推理插件—— 每种推理方式都是一个实现了统一接口的类,写在配置里就能热插拔。本章讲两件事:(1)这套插件机制 怎么让 reasoning 和 index 都变成可替换的扩展点;(2)其中的agent 式推理(ReAct 与 ReWOO) 怎么把文档检索包装成"工具"喂给 agent,让它多步推理。

04 的边界: 04 讲默认的直答链(检索 → 拼证据 → 一次 LLM 调用出答案 + 引用)。 本章讲的是另一类 reasoning:agent 式多步推理(想一步、调一次工具、看结果、再想), 以及让这些 reasoning 能被随意替换的整体架构。04 是"其中一个插件的内部",本章是"插件的插槽 + 两个 agent 插件"。


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

一句话定义: kotaemon 不把"回答逻辑"写死,而是定义一个插槽,任何满足接口的"推理管线" 都能插进去;用户在界面上像选单选按钮一样切换。

解决什么问题: 同一个知识库,不同问题需要不同打法。

  • 事实型问题("这份合同的违约金是多少?")—— 直接检索 + 一次问答就够(04 的默认链)。
  • 复杂/多跳问题("对比这两份年报里研发投入的变化,并结合行业背景解释")—— 需要多步: 先查 A、再查 B、可能再上网查行业数据,最后综合。

如果把回答逻辑写死成一条链,想加一种打法就得改核心代码。kotaemon 的做法是:把每种打法做成插件。

用起来什么样: 配置文件里列出所有可用的推理插件,应用启动时自动加载,界面上就多出对应的单选项:

# flowsettings.py:319 —— 这一个列表就是"装了哪些推理插件"
KH_REASONINGS = [
"ktem.reasoning.simple.FullQAPipeline", # 默认直答(第 04 章)
"ktem.reasoning.simple.FullDecomposeQAPipeline", # 拆解子问题
"ktem.reasoning.react.ReactAgentPipeline", # ReAct agent(本章)
"ktem.reasoning.rewoo.RewooAgentPipeline", # ReWOO agent(本章)
]

想加第五种推理?写一个类、把它的点分路径字符串追加进这个列表即可,核心代码一行不动。 这就是"可插拔"的直观体感。

一句话直觉: 把 reasoning 想成打印机驱动——应用是操作系统,只认一个统一接口 (get_info / get_user_settings / get_pipeline / run);装什么"驱动"由配置决定,换驱动不用重装系统。


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

kotaemon 有两个平行的插件体系:reasoning(怎么回答)和 index(信息存哪、怎么取)。 两者用的是同一套"配置里写点分路径字符串 → 启动时动态导入 → 注册进表 → 运行时按需取"的模式。

怎么读下图: 从上到下是"配置 → 注册 → 运行"三个阶段;左边是 reasoning 插件线,右边是 index 插件线, 两条线在运行时的 create_pipeline 汇合。

flowsettings.py(配置 = 装了哪些插件)
KH_REASONINGS = [...] KH_INDEX_TYPES / KH_INDICES = [...]
│ │
┌────────────┴───────────┐ ┌─────────────┴──────────────┐
│ 应用启动:动态注册 │ │ 应用启动:动态注册 │
│ BaseApp │ │ IndexManager │
│ .register_reasonings() │ │ .on_application_startup() │
│ import_dotted_string │ │ import_dotted_string │
│ ↓ │ │ ↓ │
│ reasonings[id] = cls │ │ indices.append(index) │
└────────────┬───────────┘ └─────────────┬──────────────┘
│ │
└──────────────┬───────────────────────┘
↓ 用户提问时
ChatPage.create_pipeline()
① 按用户选的模式取 reasoning 类
② 向每个 index 要 retriever 管线
③ reasoning_cls.get_pipeline(settings, state, retrievers)

一个"活的"推理管线,.stream(message) 出答案

部件一句话职责:

部件干什么在哪个文件
KH_REASONINGS声明装了哪些推理插件(点分路径字符串)flowsettings.py:319
BaseReasoningreasoning 插件的统一接口libs/ktem/ktem/reasoning/base.py:6
register_reasonings启动时把配置里的类动态导入并注册进 reasoningslibs/ktem/ktem/app.py:95
reasonings(dict)全局注册表:id → 类libs/ktem/ktem/components.py:185
BaseIndexindex 插件的统一接口(索引+检索)libs/ktem/ktem/index/base.py:14
IndexManager动态加载 index 类型、建库、启动libs/ktem/ktem/index/manager.py:12
ReactAgentPipelineReAct 推理插件(边想边做)libs/ktem/ktem/reasoning/react.py:181
RewooAgentPipelineReWOO 推理插件(先规划后求解)libs/ktem/ktem/reasoning/rewoo.py:211
ReactAgent / RewooAgentagent 的底层引擎(在 kotaemon 库里)libs/kotaemon/kotaemon/agents/react/agent.py:18rewoo/agent.py:22

主线走一遍(高层): 用户提问 → create_pipeline 按当前选中的模式从 reasonings 表取类 → 向所有 index 要 retriever → 把 retriever 交给 reasoning 的 get_pipeline 组装出管线 → 管线 .stream() 逐段吐出答案和中间步骤 → UI 渲染。


3. 核心机制之一:reasoning 怎么被当插件热插拔

3.1 统一接口:BaseReasoning

它要解决的小问题: 应用不想知道每种推理的内部;它只想对着一个固定接口说话。

BaseReasoning(libs/ktem/ktem/reasoning/base.py:6)就是这份契约,四个方法:

方法谁调、干什么
get_info()返回 {id, name, description}——应用用它在界面上列出这个插件
get_user_settings()返回这个插件独有的设置项(用哪个 LLM、开哪些工具…),自动渲染成设置页
get_pipeline(settings, state, retrievers)工厂方法:按当前设置 + 检索器,组装出一个可运行的管线实例
run / stream真正执行推理,逐段产出答案

关键设计:get_pipeline 是类方法(工厂),不是构造函数。它接收运行时的 settingsretrievers,现场把参数灌进去再返回实例——所以同一个类能按不同设置产出不同行为的管线。

# libs/ktem/ktem/reasoning/base.py:35 —— 契约里的工厂方法(示意精简)
@classmethod
def get_pipeline(cls, user_settings, state, retrievers=None) -> "BaseReasoning":
"""按用户设置 + 检索器,组装出一个能跑的推理管线"""
return cls()

3.2 动态注册:从字符串到类

它要解决的小问题: 配置里写的是字符串("ktem.reasoning.react.ReactAgentPipeline"), 怎么变成真正的类?

答案是 import_dotted_string(theflow 库提供的"按点分路径 import 一个对象")。 应用启动时,register_reasonings 遍历 KH_REASONINGS,逐个 import、取出 id、塞进全局注册表:

# libs/ktem/ktem/app.py:95 —— register_reasonings(精简)
for value in settings.KH_REASONINGS: # 配置里的字符串列表
reasoning_cls = import_dotted_string(value, safe=False) # 字符串 → 类
rid = reasoning_cls.get_info()["id"] # 问它:你叫什么 id?
reasonings[rid] = reasoning_cls # 注册进全局表
options = reasoning_cls().get_user_settings() # 收集它的专属设置项
self.default_settings.reasoning.options[rid] = BaseSettingGroup(settings=options)

到这一步,reasonings 这个全局 dict(libs/ktem/ktem/components.py:185)就成了 id → 类的路由表。加插件 = 往 KH_REASONINGS 加一行字符串,其余全自动。

除了配置里的内置 reasoning,register_extensions(libs/ktem/ktem/app.py:109)还用 pluggy(Python 的插件框架)加载第三方扩展包声明的 reasoning——所以外部 pip 包也能贡献推理插件。

3.3 运行时汇合:create_pipeline

用户提问时,ChatPage.create_pipeline(libs/ktem/ktem/pages/chat/__init__.py:1182)把两条线接起来:

# libs/ktem/ktem/pages/chat/__init__.py:1223 —— 运行时组装(精简)
reasoning_cls = reasonings[reasoning_mode] # ① 按用户选的模式取类

retrievers = []
for index in self._app.index_manager.indices: # ② 向每个 index 要检索器
retrievers += index.get_retriever_pipelines(settings, user_id, index_selected)

pipeline = reasoning_cls.get_pipeline(settings, reasoning_state, retrievers) # ③ 组装

注意 reasoning 和 index 在这里解耦又协作:reasoning 不关心检索器从哪来、是向量还是全文, 它只拿到一串"给个 query 就吐文档"的 retrievers;index 也不关心谁在用它的检索结果。


4. 核心机制之二:index 也是插件

它要解决的小问题: 一个应用可能有多个知识源——本地文件夹、Google Drive、Slack 消息…… 每种源的"怎么存、怎么取"都不同,但应用只想统一管理。

kotaemon 的答案和 reasoning 同构:BaseIndex(libs/ktem/ktem/index/base.py:14)是接口, IndexManager(libs/ktem/ktem/index/manager.py:12)是加载器。

BaseIndex 里两个方法直接对应"存"和"取"这两件事:

方法干什么
get_indexing_pipeline(settings, user_id)返回索引管线——把文档灌进库(切分、embed、入库,见 02)
get_retriever_pipelines(settings, user_id, selected)返回检索管线列表——给 query 吐文档(见 03)

get_retriever_pipelines 的返回值,正是上一节 create_pipeline 里喂给 reasoning 的那串 retrievers这就是 index 插件与 reasoning 插件的接缝。

IndexManager 的动态加载也用同一招 import_dotted_string:

# libs/ktem/ktem/index/manager.py:154 —— 加载 index 类型(精简)
for index_str in settings.KH_INDEX_TYPES: # 配置里的点分路径字符串
cls = import_dotted_string(index_str, safe=False)
self._index_types[f"{cls.__module__}.{cls.__qualname__}"] = cls

扩展点小结: 想加一种知识源,写个 BaseIndex 子类、把路径加进 KH_INDEX_TYPES; 想加一种回答方式,写个 BaseReasoning 子类、把路径加进 KH_REASONINGS。两个扩展点、同一套机制。


5. 核心机制之三:agent 式推理怎么工作

前面讲的是"插槽";这一节讲两个具体的 agent 插件,以及它们和 04 默认链的本质区别。

共同前提:检索被包装成一个"工具"。 两个 agent 都不直接调 retriever,而是把 retriever 包成一个叫 docsearchDocSearchTool,连同 LLM、Wikipedia、Google、MCP 等工具一起, 组成 agent 的"工具箱",让 agent 自己决定何时用哪个。

# libs/ktem/ktem/reasoning/react.py:34 —— 把检索器包装成工具(精简)
class DocSearchTool(BaseTool):
name: str = "docsearch"
description: str = "内部文档库;缺私有信息时来这里搜。" # ← 这段话会进 LLM 的 prompt
retrievers: list[BaseComponent] = []

def _run_tool(self, query):
docs = []
for retriever in self.retrievers: # 调所有检索器(03 章的混合检索)
for doc in retriever(text=query):
docs.append(doc)
return self.prepare_evidence(docs) # 拼成证据文本喂回 agent

工具的 name + description 会被拼进 agent 的 prompt(_compose_plugin_description, libs/kotaemon/kotaemon/agents/react/agent.py:48),LLM 靠这段描述判断"这个工具是干嘛的、该不该调"。

5.1 ReAct:边想边做(Reason + Act 交错)

思路: ReAct(论文 2210.03629)让 LLM 循环产出 Thought(想)→ Action(选工具)→ Action Input(工具输入),系统执行工具得到 Observation(观察),再把观察塞回 prompt 让 LLM 继续想——直到它输出 Final Answer

Question ─→ [LLM] ──Thought──→ Action: docsearch[违约金条款]

▼ 执行工具
Observation: <检索到的证据>
│ 塞回 scratchpad

[LLM] ──Thought──→ Action: llm[综合上面回答]


[LLM] ──Thought: I now know──→ Final Answer: ...

驱动这个循环的是一段格式化 prompt —— 它教 LLM 严格按 Thought/Action/Action Input/Observation 的格式说话(DEFAULT_QA_PROMPT,libs/ktem/ktem/reasoning/react.py:128;底层模板见 react/prompt.py:5zero_shot_react_prompt)。

真实的循环体在 ReactAgent.run:

# libs/kotaemon/kotaemon/agents/react/agent.py:204 —— ReAct 主循环(精简)
for step_count in range(1, max_iterations + 1):
prompt = self._compose_prompt(instruction) # 把历史 scratchpad 拼进 prompt
response = self.llm(prompt, stop=["Observation:"]) # 让 LLM 想到"该观察了"就停
action_step = self._parse_output(response.text) # 正则解析出 Action / Final Answer
if isinstance(action_step, AgentFinish): # 已给终答 → 结束
break
result = function_map[action_name](tool_input) # 否则:执行选中的工具
result = self._trim(result) # 裁剪工具输出(防超长)
self.intermediate_steps.append((action_step, result)) # 记进 scratchpad,下轮带上

三个关键细节:

  • stop=["Observation:"]:让 LLM 只产出到"该看结果了"就打住,由系统去真正执行工具—— 防止 LLM 自己幻想观察结果。
  • _parse_output(agent.py:74) 用正则区分"这是一次工具调用"还是"这是最终答案"。
  • max_iterations 兜底:想不出终答就在第 N 轮 stopped,不会无限循环。

ReactAgentPipeline.get_pipeline(react.py:261)负责组装:选 LLM、按用户勾选的工具名 从 TOOL_REGISTRY 取工具、把 retriever 注入 DocSearchTool,再把工具列表塞给 agent.plugins

5.2 ReWOO:先规划后求解(Reasoning WithOut Observation)

思路: ReWOO(论文 2305.18323)把"想"和"做"彻底分成两阶段:

  1. Planner(规划器) 一次性写出整个计划——每步一个 plan + 一次工具调用,证据存进变量 #E1#E2……后面的步骤可以引用前面的 #E
  2. Worker(执行器) 按计划把所有工具调用跑完,填出每个 #E 的真实值。
  3. Solver(求解器) 拿到"计划 + 全部证据",一次性总结出最终答案。

关键区别:Planner 规划时看不到工具的真实返回(WithOut Observation)——它一次把整盘棋想完。

┌─────────── 阶段一:Planner(一次 LLM 调用) ───────────┐
Question ─→ │ #Plan1: 查违约金 #E1: docsearch[违约金条款] │
│ #Plan2: 查行业惯例 #E2: wikipedia[合同违约金 惯例] │
│ #Plan3: 综合 #E3: llm[结合 #E1 #E2 分析] │
└────────────────────────────┬─────────────────────────┘
▼ 阶段二:Worker 按 DAG 执行
#E1、#E2 无依赖 → 并行跑;#E3 依赖 #E1/#E2 → 后跑
▼ 阶段三:Solver(一次 LLM 调用)
计划 + 全部证据 ─→ 最终答案

Planner 的 prompt 明确教它输出 #Plan / #E 格式并允许 #E 互相引用 (DEFAULT_PLANNER_PROMPT,libs/ktem/ktem/reasoning/rewoo.py:31);Solver 的 prompt 则是 "逐条总结 + 给结论"(DEFAULT_SOLVER_PROMPT,rewoo.py:50)。三阶段串在 RewooAgent.run:

# libs/kotaemon/kotaemon/agents/rewoo/agent.py:266 —— ReWOO 三阶段(精简)
# ① Plan:一次性产出全计划
planner_output = self.planner(instruction)
plan_to_es, plans = self._parse_plan_map(planner_output.text) # 解析 #Plan→#E
planner_evidences, evidence_level = self._parse_planner_evidences(...) # 解析依赖分层

# ② Work:按依赖层级执行(同层并行)
worker_evidences, _, _ = self._get_worker_evidence(planner_evidences, evidence_level)

# ③ Solve:拿计划+证据,一次出终答
solver_output = self.solver(instruction, worker_log)

ReWOO 最妙的一处:并行执行。 _parse_planner_evidences(rewoo/agent.py:108)从计划里 解析出 #E 之间的依赖,做成 DAG 分层;_get_worker_evidence(rewoo/agent.py:194)用 ThreadPoolExecutor 让同一层里互不依赖的工具调用并行跑:

# libs/kotaemon/kotaemon/agents/rewoo/agent.py:214 —— 同层并行(精简)
with ThreadPoolExecutor() as pool:
for level in evidences_level: # 一层一层来
results = [pool.submit(self._run_plugin, e, ...) for e in level] # 同层并行提交
for r in results:
worker_evidences[resp["e"]] = self._trim_evidence(r.result()["evidence"])

Planner/Solver 甚至可以配不同的 LLM——RewooAgentPipeline.get_pipeline(rewoo.py:386) 分别读 planner_llmsolver_llm 设置,让你"用便宜模型规划、贵模型求解"或反之。

5.3 ReAct vs ReWOO 取舍

维度ReAct(边想边做)ReWOO(先规划后求解)
LLM 调用次数每步一次(N 步 = N+ 次)基本固定两次(规划 1 + 求解 1)
看不看工具返回再决定下一步(动态适应)不看(计划一次定死)
工具执行串行(下一步依赖上一步观察)同层可并行(DAG)
擅长探索式、路径不定、需随观察调整步骤可预先想清、想省 token/想并行提速
弱点LLM 调用多、慢、易跑偏累积规划错了没法中途纠正(没有观察反馈)
引用高亮无专门支持支持(enable_citation,把证据回锚到 worker_log)

一句话:路径未知、要边走边看 → ReAct;步骤能提前想清、想快想省 → ReWOO。


6. agent 的工具箱

两个 agent 共享同一套工具抽象:所有工具都是 BaseTool(libs/kotaemon/kotaemon/agents/tools/base.py:19) 的子类,核心就一个 _run_tool(输入) → 输出name + description 决定 LLM 怎么认识它。

工具干什么文件
DocSearchTool调本地检索器搜内部文档(03 章的混合检索)libs/ktem/ktem/reasoning/react.py:34
LLMTool直接问 LLM 的通用世界知识libs/kotaemon/kotaemon/agents/tools/llm.py:15
GoogleSearchToolGoogle 联网搜索libs/kotaemon/kotaemon/agents/tools/google.py
WikipediaTool查维基百科页面libs/kotaemon/kotaemon/agents/tools/wikipedia.py:48
MCPTool把外部 MCP 服务器的工具桥接成 BaseToollibs/kotaemon/kotaemon/agents/tools/mcp.py:282

MCP 桥接是又一个可插拔层: create_tools_from_config(mcp.py:168)连上一个 MCP(Model Context Protocol)服务器、列出它暴露的工具、把每个都包成 MCPTool, 于是任何 MCP 服务器的能力都能直接变成 agent 的工具——无需改 agent 代码。两个 pipeline 的 get_pipeline 里都有识别 mcp- 前缀、动态拉起 MCP 工具的分支(react.py:284rewoo.py:410)。

工具在 TOOL_REGISTRY(react.py:121)里按名字登记;用户在设置页勾哪些工具,get_pipeline 就取哪些塞进 agent.plugins这又是一层扩展点:加个工具类、登记进 registry 即可。


7. 边界与局限

  • ReAct 依赖 LLM 严格守格式。 靠正则解析 Action:/Action Input:(agent.py:84)。 模型不听话(格式跑偏)时,strict_decode=False 会把整段当成 Final Answer 兜底 (agent.py:108)——可能提前结束、答得不完整。
  • ReWOO 规划一次定死,不能中途纠错。 Planner 看不到工具返回;若计划本身有误, Worker/Solver 只能将错就错。DAG 里检测到循环依赖会直接抛 Circular dependency detected (rewoo/agent.py:141)。
  • 工具输出会被裁剪。 为防超长,每个工具返回都按 token 裁剪(ReAct 的 _trim、 ReWOO 的 _trim_evidence),证据可能被截断。
  • LLMTool 默认 dummy。 dummy_mode=True(llm.py:25)时它其实不调 LLM、只回占位符—— 真正启用需在组装时关掉。

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

主题文件路径符号名
reasoning 插件接口libs/ktem/ktem/reasoning/base.pyBaseReasoningget_infoget_user_settingsget_pipeline
动态注册 reasoninglibs/ktem/ktem/app.pyregister_reasoningsregister_extensionsimport_dotted_string
reasoning 全局注册表libs/ktem/ktem/components.pyreasonings
装了哪些插件(配置)flowsettings.pyKH_REASONINGSKH_INDEX_TYPESKH_INDICES
运行时组装管线libs/ktem/ktem/pages/chat/__init__.pycreate_pipeline
index 插件接口libs/ktem/ktem/index/base.pyBaseIndexget_indexing_pipelineget_retriever_pipelines
index 动态加载/建库libs/ktem/ktem/index/manager.pyIndexManagerload_index_typeson_application_startup
ReAct 推理插件libs/ktem/ktem/reasoning/react.pyReactAgentPipelineDocSearchToolTOOL_REGISTRYDEFAULT_QA_PROMPT
ReWOO 推理插件libs/ktem/ktem/reasoning/rewoo.pyRewooAgentPipelineDEFAULT_PLANNER_PROMPTDEFAULT_SOLVER_PROMPT
ReAct 引擎(想-做循环)libs/kotaemon/kotaemon/agents/react/agent.pyReactAgentrun_parse_output_compose_prompt
ReAct prompt 模板libs/kotaemon/kotaemon/agents/react/prompt.pyzero_shot_react_prompt
ReWOO 引擎(规划-执行-求解)libs/kotaemon/kotaemon/agents/rewoo/agent.pyRewooAgentrun_parse_planner_evidences_get_worker_evidence
ReWOO 规划器 / 求解器libs/kotaemon/kotaemon/agents/rewoo/planner.pysolver.pyPlannerSolver
工具抽象libs/kotaemon/kotaemon/agents/tools/base.pyBaseTool_run_toolComponentTool
内置工具libs/kotaemon/kotaemon/agents/tools/LLMToolGoogleSearchToolWikipediaTool
MCP 工具桥接libs/kotaemon/kotaemon/agents/tools/mcp.pyMCPToolcreate_tools_from_configdiscover_tools_info

延伸阅读: 组件模型见 01;索引管线见 02; 混合检索见 03;默认直答链见 04