提示词为什么放在几十个 yaml 里而不是写死在代码中

2026-08-10

把提示词从代码里挪到外部文件,是多 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 个 .mddeeptutor/agents/vision_solver/prompts/geogebra.md,85 行)。

也就是说,这个目录里将近四成的文件不是代码,是提示词。这批 yaml 按模块的分布是这样(统计命令是对每个模块跑 find deeptutor/agents/<m>/prompts -name "*.yaml" | wc -l):

模块yaml 数
chat4
math_animator12
notebook4
question8
research2
visualize8
合计38

enzh 各 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_nameagent_namelanguage 三个都是构造参数(base_agent.py:51-64language 默认 "zh")。所以这三个字符串不是标签,是路由键——它们直接拼成磁盘上的一条路径。这也是”写死在代码里”与”外置成 yaml”最实际的差别:前者写错了,至少在改代码时它就在你眼前;后者写错了只是一条查不到的路径,磁盘上没有任何东西会替你喊一声。

那查不到会怎样?base_agent.py:135-145 的处理是把 self.prompts 置为 None、打一条 warning,构造函数继续走完。agent 照样创建成功。

三、反直觉的那一处:这套约定没有强校验的一侧

按直觉,既然提示词外置成了几十个文件,总该有一份注册表把”哪些模块该有哪些 yaml”钉住,启动时对一遍。仓库里确实有一个看着像注册表的东西——PromptManager.MODULESservices/prompt/manager.py:29-39)。但它有两处可核实的情况:

  1. MODULES 这个名字,除定义处之外在仓库里没有任何引用(grep -rn "MODULES" deeptutor/ tests/)。
  2. 这份列表含 "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-26123-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 正文,不下结论。能确定的是结构层面的事实:execcode_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 那种按块名装配的形态更是要在代码里维护一张块表。外置之后,这两件事都退化成了字符串拼接加路径查找。代价就是第三节说的那个:查找失败没有编译期保护。

六、你可以照着核的五步

  1. 打开 deeptutor/services/prompt/manager.py:136-145,确认 _candidate_prompt_dirs() 就是按 <module>/prompts/{en,zh}/ 这条约定拼路径的。
  2. 回到 deeptutor/agents/base_agent.py:135-145,确认加载失败的处理是 self.prompts = None 加一条 warning,而不是抛异常。
  3. 在仓库根跑 find deeptutor/agents -name "*.yaml" | wc -l,看是不是 38;再按模块分别数一遍,和上面那张表比对。
  4. grep -rn "MODULES" deeptutor/ tests/,看 PromptManager.MODULES 除定义处外有没有第二处引用;再对照 vision_solver/vision_solver_agent.py:32 传的 module_name
  5. 打开 chat/prompt_blocks.py:12-56visualize/agents/code_generator_agent.py:37-49,把两种拼装风格并排看一遍——这决定了你想改哪句话时该去动 yaml 还是动拼装代码。

需要说明的边界:这 38 个提示词 yaml 的正文内容我们一条都没读,本文只统计了文件数、行数与加载路径。PromptManager 也只核到了候选目录解析与语言回退这两段,缓存实现与 load_prompts() 的返回结构没有逐行读。我们没有安装或运行过这个项目,凡是涉及”加载之后模型会怎么答”的部分,本文一律不下结论。上面出现的所有默认值都是源码里的默认配置,不是运行结果的保证。


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

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