提示词为什么放在几十个 yaml 里而不是写死在代码中
把提示词从代码里挪到外部文件,是多 agent 项目做到一定体量后几乎都会走的一步。但”挪出去”这个动作本身不解决问题,真正决定这套东西好不好用的是三件事:文件按什么规则被找到、找不到的时候会发生什么、运行时它是整段发出去还是被拆开重拼。DeepTutor 这三件事都能落到具体行号上。
下面的行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名和字段名是稳的。
一、先看这批 yaml 在目录里占多大
deeptutor/agents/ 下共 102 个文件、合计 20594 行。其中 .py 63 个、17498 行,.yaml 38 个,另有 1 个 .md(deeptutor/agents/vision_solver/prompts/geogebra.md,85 行)。
也就是说,这个目录里将近四成的文件不是代码,是提示词。这批 yaml 按模块的分布是这样(统计命令是对每个模块跑 find deeptutor/agents/<m>/prompts -name "*.yaml" | wc -l):
| 模块 | yaml 数 |
|---|---|
| chat | 4 |
| math_animator | 12 |
| notebook | 4 |
| question | 8 |
| research | 2 |
| visualize | 8 |
| 合计 | 38 |
en 与 zh 各 19 份。这张表要配着文件体积一起读才有信息量:最大的两份提示词是 question/prompts/en/pipeline.yaml(353 行)与 zh/pipeline.yaml(333 行),其次是 research/prompts/en/pipeline.yaml(319 行)。research 只有 2 个 yaml,但它的 research/pipeline.py 是全目录最长的文件(2871 行)。文件个数和提示词体积不是一回事,math_animator 有 12 个 yaml 却没有一份进前三。
至于服务侧,deeptutor/services/prompt/ 这个子领域只有 3 个文件、370 行——加载器本身很薄,重量全在被加载的内容上。
二、路径是靠目录约定解析的,不是靠注册表
约定路径是:
deeptutor/agents/<module>/prompts/{en,zh}/<agent_name>.yaml
解析这条路径的是 PromptManager._candidate_prompt_dirs()(deeptutor/services/prompt/manager.py:136-145)。入口在基类里:BaseAgent.__init__ 调 get_prompt_manager().load_prompts(module_name, agent_name, language)(deeptutor/agents/base_agent.py:135-145)。
module_name、agent_name、language 三个都是构造参数(base_agent.py:51-64,language 默认 "zh")。所以这三个字符串不是标签,是路由键——它们直接拼成磁盘上的一条路径。这也是”写死在代码里”与”外置成 yaml”最实际的差别:前者写错了,至少在改代码时它就在你眼前;后者写错了只是一条查不到的路径,磁盘上没有任何东西会替你喊一声。
那查不到会怎样?base_agent.py:135-145 的处理是把 self.prompts 置为 None、打一条 warning,构造函数继续走完。agent 照样创建成功。
三、反直觉的那一处:这套约定没有强校验的一侧
按直觉,既然提示词外置成了几十个文件,总该有一份注册表把”哪些模块该有哪些 yaml”钉住,启动时对一遍。仓库里确实有一个看着像注册表的东西——PromptManager.MODULES(services/prompt/manager.py:29-39)。但它有两处可核实的情况:
MODULES这个名字,除定义处之外在仓库里没有任何引用(grep -rn "MODULES" deeptutor/ tests/)。- 这份列表含
"solve",不含"vision_solver";而VisionSolverAgent确实以module_name="vision_solver"调用 BaseAgent(vision_solver/vision_solver_agent.py:32)。
这两处位置都能自己去核,我们只记录差异,不推断哪一边该改。
vision_solver 还是另一处例外:它的提示词根本不是 yaml,而是 prompts/geogebra.md,由 agent 自己用 Path(__file__).parent / "prompts" / "geogebra.md" 直接读,不走 PromptManager;文件缺失时同样只是 warning(vision_solver/vision_solver_agent.py:40-44)。
把这几条拼起来看,这套外置方案的真实形态是:目录约定 + 运行期查找 + 缺失降级为 warning,而不是”注册表 + 启动时校验”。这不是设计上的对错问题,但它对排查方式有直接影响——提示词层出问题时,从代码语义看你不会收到异常堆栈,只会在日志里多一条 warning;至于这对最终生成结果有什么影响,涉及运行表现,我们没有依据、不下结论。所以日志级别怎么配,在这个项目里是有实际后果的。
配套还有一处能佐证”缺失被当作可恢复状态”的实现:ConceptAnalysisAgent 里有一段针对提示词加载失败的一次性重试逻辑,注释写明 PromptManager 已不再缓存 miss,所以 reload 能恢复(math_animator/agents/concept_analysis_agent.py:40-52)。这段代码本身就说明”第一次没加载到”是被预期会发生的。
四、语言回退链:zh 找不到会落到 en
多语言是提示词外置最直接的收益,也是把它写死在 Python 字符串里最难受的部分。DeepTutor 的回退链写在 services/prompt/manager.py:22-26 与 123-134:
zh→[zh, cn, en]en→[en, zh, cn]
并且最终一定兜到 en。
对读者的实际含义是:某个模块如果只提供了 en 目录下的 yaml,你把 language 设成 zh 也不会报错,加载器会沿链找到那份 en 文件。这解释了为什么 en/zh 各 19 份看着整齐,但你不能反推”每个 agent 都有中英两份”——这个数字只说明总量,具体到某个 <agent_name>.yaml 是否两边都有,得自己去对应目录下看一眼。至于回退之后模型用哪种语言作答,涉及运行结果,我们没有依据,不下结论。
五、yaml 不是整段发出去的:运行时还要拼
外置带来的第二个后果是:提示词从”一段字符串”变成了”一批可组合的素材”。仓库里有两个不同风格的拼装点。
chat 走的是显式命名的分块。 ChatPromptAssembler 把系统提示词按块拼装,块之间用 \n\n---\n\n 连接,每块加一个 ## <name> 标题(chat/prompt_blocks.py:12-56)。可拼进去的块包括 tool_manifest、kb_note、deferred_tools_manifest、notebook_manifest、workspace_note、capability_blocks(chat/prompt_blocks.py:21-42)。
这里要如实说明一件事:块名里有一项叫 tool_manifest,与它同一层的工具组合策略集中在 _shared/tool_composition.py;至于这个块具体拼进了什么内容,我们没有读 yaml 正文,不下结论。能确定的是结构层面的事实:exec 与 code_execution 都在条件挂载表里(_shared/tool_composition.py:46-57),改这个块所在的这一层,等于改模型看到的可用动作清单。代码执行这一侧另有 deeptutor/services/sandbox,它的自述是”沙箱 shell 执行,可插拔隔离后端 + 按用户配额”(sandbox/__init__.py:1),三种隔离后端里 RestrictedSubprocessBackend 的注释明写 “admin-opt-in only because it does not OS-isolate”(sandbox/backends.py:34-50)。我们没有运行过这套东西,涉及本机执行的部分请结合自身环境评估。
visualize 走的是按输出类型拼。 CodeGeneratorAgent 按渲染类型组装系统提示词,结构是 system_base + rules_general + rules_<render_type>,注释说明这样”不必为四种格式的并集付费”(visualize/agents/code_generator_agent.py:37-49)。而 visualize/models.py:12-19 里的 RenderType 是一个 6 值 Literal:svg / chartjs / mermaid / html / manim_video / manim_image。注释里的措辞与这个枚举的取值数并列在这里,我们不推断哪一处对应什么。
这两处拼装点合起来能回答标题里的那半个问题:如果提示词写死在代码里,rules_<render_type> 这种”按变量拼文件名”的做法就得改成一堆分支,而 chat 那种按块名装配的形态更是要在代码里维护一张块表。外置之后,这两件事都退化成了字符串拼接加路径查找。代价就是第三节说的那个:查找失败没有编译期保护。
六、你可以照着核的五步
- 打开
deeptutor/services/prompt/manager.py:136-145,确认_candidate_prompt_dirs()就是按<module>/prompts/{en,zh}/这条约定拼路径的。 - 回到
deeptutor/agents/base_agent.py:135-145,确认加载失败的处理是self.prompts = None加一条 warning,而不是抛异常。 - 在仓库根跑
find deeptutor/agents -name "*.yaml" | wc -l,看是不是 38;再按模块分别数一遍,和上面那张表比对。 grep -rn "MODULES" deeptutor/ tests/,看PromptManager.MODULES除定义处外有没有第二处引用;再对照vision_solver/vision_solver_agent.py:32传的module_name。- 打开
chat/prompt_blocks.py:12-56与visualize/agents/code_generator_agent.py:37-49,把两种拼装风格并排看一遍——这决定了你想改哪句话时该去动 yaml 还是动拼装代码。
需要说明的边界:这 38 个提示词 yaml 的正文内容我们一条都没读,本文只统计了文件数、行数与加载路径。PromptManager 也只核到了候选目录解析与语言回退这两段,缓存实现与 load_prompts() 的返回结构没有逐行读。我们没有安装或运行过这个项目,凡是涉及”加载之后模型会怎么答”的部分,本文一律不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。