`BaseAgent` 读一遍:模型解析优先级与两个 LLM 入口

2026-08-10

读一个多 agent 项目,最省时间的办法不是从 pipeline 开始,而是先把基类读一遍——因为所有 agent 最终都要在同一处把「用哪个模型、带什么参数、怎么发出去」这几件事定下来。DeepTutor 的这个收口点就是 deeptutor/agents/base_agent.py

以下所有行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,但文件名和字段名是稳的,照着找即可。

一、先看这个文件在目录里的位置

deeptutor/agents/ 这一层顶层只有两个 py 文件__init__.py(36 行)和 base_agent.py(769 行)。其余全在若干子包里(子包怎么划分,我们另有一篇专门讲)。

也就是说,这一层是刻意压扁的:没有中间抽象层,子包里的 agent 直接继承顶层这一个类。deeptutor/agents/ 内的 BaseAgent 子类共 11 个,全仓范围是 17 个(另外 6 个在 deeptutor/book/agents/deeptutor/co_writer/edit_agent.pydeeptutor/services/session/context_builder.py)。

它在整条调用链上的位置,仓库自己在 deeptutor/services/llm/__init__.py:7-17 的 docstring 里画出来了:Agents → BaseAgent.call_llm()/stream_llm() → LLM Factory(complete / stream)→ CloudProvider / LocalProvider。基类是这条链上唯一一个”所有 agent 都必经”的环节,所以排查「模型配错了 / 参数没生效」这类问题,从这里往下走比从 pipeline 往回追要快。

类定义在 base_agent.py:32class BaseAgent(ABC)。它对外只强制一件事:唯一的抽象方法是 async def process(self, *args, **kwargs)base_agent.py:746-753)。除此之外全是它替子类提供的能力,类 docstring 列了六项(base_agent.py:33-45):LLM 配置管理、来自 agents.yaml 的 agent 参数(temperature / max_tokens)、通过 PromptManager 加载提示词、统一的 LLM 调用接口、token 追踪、日志。

二、构造参数里藏着两个「路由键」

构造函数的参数在 base_agent.py:51-64module_nameagent_nameapi_keybase_urlmodelapi_versionlanguage(默认 "zh")、bindingconfigtoken_trackerlog_dir

前两个不是普通标签,它们是路由键:

  • 提示词按它们定位__init__ 里调 get_prompt_manager().load_prompts(module_name, agent_name, language)base_agent.py:135-145)。约定路径是 deeptutor/agents/<module>/prompts/{en,zh}/<agent_name>.yaml。这两个名字写错,找不到的就是提示词文件。
  • token 统计按 module_name 分桶。类级共享的 LLMStats 就是按 module 名分桶的(base_agent.py:47-48),按字面语义,同一个 module 下的多个 agent 实例共用同一个统计桶。

这里有第一处容易翻车的地方:提示词加载失败不是硬错误base_agent.py:135-145 的处理是把 self.prompts 置为 None 并打一条 warning,然后构造继续走完。对调用方来说,agent 照样创建成功,问题要到真正取提示词的时候才暴露出来。所以看到「输出完全不对劲但没有任何异常」时,先去日志里找这条 warning,而不是先怀疑模型。

三、模型解析优先级:显式传的 model 只排第三

这是本文标题里的那一半,也是这个类里最反直觉的一处。

base_agent.py:151-178 这段 docstring 把优先级写死为:agent_config > llm_config > self.model > 环境变量;这条链走完仍然没有取到值,就抛 ValueError

反直觉在哪?在于 self.model 排在第三位,前面压着两层配置。self.model 是实例上的 model 字段,构造参数里确实有同名的 model=base_agent.py:51-64);但它具体怎么赋值、agent_configllm_config 这两层各自从哪里读,都超出了我们这次核对的范围,不做等价、也不替它补来源。能确定的只有一件事:链上排第三的那一级,名字叫 self.model

按大多数人的直觉,“我在代码里显式写死的参数”应该是最高优先级;这里恰好相反,前两级配置都排在它前面。这个顺序本身没有对错,它服务的是「配置改了、所有 agent 跟着改」这类诉求。但它带来一个具体后果:你在构造 agent 时传的 model,未必是最终跑的那个。要确认到底跑的是哪个模型,别停在调用点上看自己传了什么,得回到这条链上逐级找源头——其中第四级”环境变量”具体是哪个变量名,我们没有核实,需要你自己回源码看那段 docstring。

第二个后果是:这一层没有兜底默认模型。链走空了直接抛 ValueError,而不是悄悄落到某个内置默认值上。仓库别处确实存在带 fallback 默认值的 provider 常量,但那是 services 层各自的事,BaseAgent 这一层不提供这种兜底。对读源码的人来说这是好事:模型没配就直接炸,比跑出一个你没预期的模型要好排查。

配套的还有一个 refresh_config()base_agent.py:207-231),作用是在不重启进程的前提下重新读取用户在 Settings 里改过的 LLM 配置。它解决的是「配置改了但进程里的 agent 还端着旧值」这一类问题。至于刷新之后下游的 provider 实例会不会跟着重建——deeptutor/services/llm/provider_factory.py:1728-42 记录了一个实例池,上限 _PROVIDER_POOL_MAXSIZE = 2,缓存键里除了 provider 名、模式、模型,还包含事件循环、URL、生成参数等一串要素。我们没有逐行读它的失效逻辑,所以这里只陈述缓存键包含哪些字段,不推断刷新配置之后一定会/不会命中新实例。

四、两个 LLM 入口:call_llm()stream_llm()

基类对外只开两个口子:

入口位置形态
call_llm()base_agent.py:348非流式,返回结果
stream_llm()base_agent.py:511流式,AsyncGenerator

两者不是「一个套另一个」的关系,而是并列的两条路径,各自处理同一批横切逻辑。共有的三件事值得单独记:

其一,附件走同一套多模态转换。 两个入口都支持 attachments,都会调 prepare_multimodal_messages最后一条 user 消息转成多模态内容(base_agent.py:413-424571-581)。注意是最后一条——不是全部消息,也不是第一条。

其二,trace 事件是结构化发出去的。 set_trace_callback() 负责注册回调(base_agent.py:233-246),_emit_trace_event() 负责发送(430-441625-631661-669)。事件的 state 取值有四个:running / streaming / complete / error。要做可观测性接管,钩子就在这里,不用去改 pipeline。

其三,token 统计是双路的。 一路是外部传入的 TokenTracker(可选),另一路是前面说的类级共享 LLMStatsbase_agent.py:47-48252-265297-342)。两路并存意味着你如果只接了其中一路,看到的数字口径可能和另一路不一样——具体差异我们没有核实,不做结论。

五、response_format 那处能力检查:会被静默跳过

如果说模型优先级是第一处反直觉,这一处是第二处,而且更容易在生产里咬人。

base_agent.py:400-411:传了 response_format 之后,它supports_response_format(binding, model) 做一次能力检查,不支持就跳过。也就是说,这个参数不会进到请求里,调用照常发出。可核查的结论到这一步为止:请求里没有这个字段。至于返回的内容还是不是结构化的,取决于模型与 provider,我们没有核实。

要判断自己会不会踩上,得看能力表这一侧。deeptutor/services/llm/capabilities.py 里有三张表:PROVIDER_CAPABILITIES 是 provider 级的,23 个 binding 键(capabilities.py:22);MODEL_OVERRIDES 是模型前缀级的,34 个键;DEFAULT_CAPABILITIES 是兜底的那一份,8 个能力字段。(后两张表的定义块我们采集到了两套行号记法,这里就不给精确行段了,直接在文件里搜常量名即可。)

deeptutor/services/provider_registry.py 里的 PROVIDERS 有 36 个 name。两边对不齐:有 16 个 provider 在能力表里没有专属条目,会落到 DEFAULT_CAPABILITIESprovider_registry.py:118-479)。

那份兜底默认值是:supports_response_format=Truesupports_streaming=Truesupports_tools=Falsesupports_vision=Falsevision_url_supported=Truesystem_in_messages=Truehas_thinking_tags=Falseforced_temperature=None

把这两件事拼起来看就有意思了:在这份兜底里,supports_response_format乐观的 True,而 supports_tools保守的 False——同一份默认值里两个方向相反的取值。需要说明的是,provider 这一侧落到 DEFAULT_CAPABILITIES 只是解析的一条支路,模型前缀级的 MODEL_OVERRIDES 会不会再改写这个值、两张表谁优先,我们没有核到解析顺序,不下结论。你能做的是先把自己用的 provider 名和模型前缀分别去两张表里比一遍。

顺带一个与 agents.yaml 的 temperature 参数相关的点:类 docstring 明写 agent 参数(temperature / max_tokens)来自 agents.yamlbase_agent.py:33-45),但能力表里对 gpt-5o1o3 三个模型前缀标了 forced_temperature: 1.0,注释引用了 issue #141(capabilities.py:291-301)。这个 forced 值具体在哪一层生效、以什么方式覆盖,我们没有读到那段代码,不做推断——但你在 agents.yaml 里调 temperature 却发现没反应时,这是个值得先去核的地方。

六、一个例外:不是所有 agent 都从这里走

「所有 agent 都继承 BaseAgent」这句话在这个仓库里不成立。deeptutor/agents/notebook/ 下有两个类名带 Agent 但不继承 BaseAgentNotebookAnalysisAgentnotebook/analysis_agent.py:27)与 NotebookSummarizeAgentnotebook/summarize_agent.py:19)。它们直接持有 get_llm_config() 并调用 llm_streamanalysis_agent.py:31-42summarize_agent.py:23-35)。

这个例外的实际含义是:前面讲的模型优先级链、trace 事件、双路 token 统计,是否在这两个类上同样成立,需要你另行核对——我们没有读它们的实现细节,这里只陈述”它们没走基类”这一个事实。做全局统计或全链路追踪时,这类旁路是最容易漏掉的。

七、两处 docstring 与磁盘对不上的地方

module_name 相关的东西时,会撞上两处可核实的不一致。按纪律,我们只陈述差异、标明位置,不推断原因,也不据此评价项目:

  1. base_agent.py:5-9 的文件 docstring 称这个基类是 solve / research / co_writer / question 四个模块的单一真源。但 deeptutor/solve 这个目录不存在(ls -d deeptutor/solve 报 No such file),solve 现在位于 deeptutor/capabilities/solve/;且实际以 module_name= 传入的取值里没有 "solve",我们数出来的取值是 chat / math_animator / question / research / vision_solver / visualize / book / co_writer。
  2. deeptutor/services/prompt/manager.py:29-39PromptManager.MODULES 列表含 "solve" 但不含 "vision_solver",而 VisionSolverAgent 确实以 module_name="vision_solver" 调用 BaseAgent(vision_solver/vision_solver_agent.py:32);同时 MODULES 这个名字除定义处外在仓库里没有任何引用。

以我们实读的仓库状态为准。说完就停。

八、你可以照着核的五步

  1. 打开 deeptutor/agents/base_agent.py:151-178,确认模型优先级链的顺序,以及走空之后是抛错而不是兜底。
  2. 打开 base_agent.py:348:511,确认两个入口是并列关系,各自都做了 attachments 与 trace 处理。
  3. 打开 base_agent.py:400-411,确认 response_format 前面那次能力检查的存在,以及不支持时的处理是跳过。
  4. 打开 deeptutor/services/llm/capabilities.py,找到 DEFAULT_CAPABILITIES,把它的八个字段抄下来,再回 provider_registry.py 看你要用的 provider 名在不在 PROVIDER_CAPABILITIES 的 23 个键里。
  5. 打开 base_agent.py:135-145,确认提示词加载失败只是 warning——这决定了你的日志级别该怎么配。

需要说明的边界:本文只读了 base_agent.py 的类定义、构造段、getter 段与两个 LLM 入口的关键分支,没有逐行读完 769 行;deeptutor/core/agentic/ 下的循环实现与 deeptutor/services/llm/ 下 provider 的具体请求组装我们都没有读,凡是涉及”发出去之后会怎样”的部分,本文一律不下结论。上面出现的所有阈值与默认值都是源码中的默认配置,不是运行结果的保证。


本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.mdpyproject.tomldeeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。 本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API, 因此不涉及生成质量、响应速度与教学效果的任何描述。 参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。