`BaseAgent` 读一遍:模型解析优先级与两个 LLM 入口
读一个多 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.py 与 deeptutor/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:32,class 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-64:module_name、agent_name、api_key、base_url、model、api_version、language(默认 "zh")、binding、config、token_tracker、log_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_config 与 llm_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:17、28-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-424 与 571-581)。注意是最后一条——不是全部消息,也不是第一条。
其二,trace 事件是结构化发出去的。 set_trace_callback() 负责注册回调(base_agent.py:233-246),_emit_trace_event() 负责发送(430-441、625-631、661-669)。事件的 state 取值有四个:running / streaming / complete / error。要做可观测性接管,钩子就在这里,不用去改 pipeline。
其三,token 统计是双路的。 一路是外部传入的 TokenTracker(可选),另一路是前面说的类级共享 LLMStats(base_agent.py:47-48、252-265、297-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_CAPABILITIES(provider_registry.py:118-479)。
那份兜底默认值是:supports_response_format=True、supports_streaming=True、supports_tools=False、supports_vision=False、vision_url_supported=True、system_in_messages=True、has_thinking_tags=False、forced_temperature=None。
把这两件事拼起来看就有意思了:在这份兜底里,supports_response_format 是乐观的 True,而 supports_tools 是保守的 False——同一份默认值里两个方向相反的取值。需要说明的是,provider 这一侧落到 DEFAULT_CAPABILITIES 只是解析的一条支路,模型前缀级的 MODEL_OVERRIDES 会不会再改写这个值、两张表谁优先,我们没有核到解析顺序,不下结论。你能做的是先把自己用的 provider 名和模型前缀分别去两张表里比一遍。
顺带一个与 agents.yaml 的 temperature 参数相关的点:类 docstring 明写 agent 参数(temperature / max_tokens)来自 agents.yaml(base_agent.py:33-45),但能力表里对 gpt-5、o1、o3 三个模型前缀标了 forced_temperature: 1.0,注释引用了 issue #141(capabilities.py:291-301)。这个 forced 值具体在哪一层生效、以什么方式覆盖,我们没有读到那段代码,不做推断——但你在 agents.yaml 里调 temperature 却发现没反应时,这是个值得先去核的地方。
六、一个例外:不是所有 agent 都从这里走
「所有 agent 都继承 BaseAgent」这句话在这个仓库里不成立。deeptutor/agents/notebook/ 下有两个类名带 Agent 但不继承 BaseAgent:NotebookAnalysisAgent(notebook/analysis_agent.py:27)与 NotebookSummarizeAgent(notebook/summarize_agent.py:19)。它们直接持有 get_llm_config() 并调用 llm_stream(analysis_agent.py:31-42、summarize_agent.py:23-35)。
这个例外的实际含义是:前面讲的模型优先级链、trace 事件、双路 token 统计,是否在这两个类上同样成立,需要你另行核对——我们没有读它们的实现细节,这里只陈述”它们没走基类”这一个事实。做全局统计或全链路追踪时,这类旁路是最容易漏掉的。
七、两处 docstring 与磁盘对不上的地方
写 module_name 相关的东西时,会撞上两处可核实的不一致。按纪律,我们只陈述差异、标明位置,不推断原因,也不据此评价项目:
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。deeptutor/services/prompt/manager.py:29-39的PromptManager.MODULES列表含"solve"但不含"vision_solver",而VisionSolverAgent确实以module_name="vision_solver"调用 BaseAgent(vision_solver/vision_solver_agent.py:32);同时MODULES这个名字除定义处外在仓库里没有任何引用。
以我们实读的仓库状态为准。说完就停。
八、你可以照着核的五步
- 打开
deeptutor/agents/base_agent.py:151-178,确认模型优先级链的顺序,以及走空之后是抛错而不是兜底。 - 打开
base_agent.py:348与:511,确认两个入口是并列关系,各自都做了 attachments 与 trace 处理。 - 打开
base_agent.py:400-411,确认response_format前面那次能力检查的存在,以及不支持时的处理是跳过。 - 打开
deeptutor/services/llm/capabilities.py,找到DEFAULT_CAPABILITIES,把它的八个字段抄下来,再回provider_registry.py看你要用的 provider 名在不在PROVIDER_CAPABILITIES的 23 个键里。 - 打开
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.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。