跳到主要内容

公共底座:Registry、YAML 装配、Agent/LLM 胶水

本章讲什么: 前面三章讲的所有「可插拔组件」是怎么从一份 YAML 变成活对象的?答案是一个不到 30 行的 Registry 加一套约定。这一章拆开底座:注册表、装配流程、Agent 的 prompt 三段式、LLM 的花费统计。读完你能看懂「type: classroom 这五个字母如何变成 ClassroomOrder 实例」。

1. Registry:整个框架的「字符串→类」中枢

它要解决的小问题: YAML 里写的全是字符串(type: verticalenv_type: task-basic),框架得据此造出对应的类实例,且不能在装配代码里写一长串 if-else。

思路: 一个全局注册表 + 一个装饰器。每个组件类用 @registry.register("名字") 把自己登记进去,装配时按名取出来调用。整个机制就这么短(registry.py:6-24):

# registry.py:6-24(节选)
class Registry(BaseModel):
name: str
entries: Dict = {}
def register(self, key: str):
def decorator(class_builder):
self.entries[key] = class_builder # 把类登记进字典
return class_builder
return decorator
def build(self, type: str, **kwargs):
return self.entries[type](**kwargs) # 按名取类并实例化

每个组件家族有自己的 registry 实例:env_registryagent_registryorder_registrydecision_maker_registry 等。于是组件类的开头都长这样:

# 示意,非源码 —— 对应各组件文件顶部的真实写法
@decision_maker_registry.register("vertical")
class VerticalDecisionMaker(BaseDecisionMaker): ...

@order_registry.register("classroom")
class ClassroomOrder(BaseOrder): ...

关键细节(import 即注册): 注册发生在「类被定义」时,所以必须先 import 到模块。environments/__init__.py:8-18 一口气 import 所有环境类,就是为了触发它们的 @register。这是「装饰器注册表」模式的经典坑——没 import 的组件等于不存在。

2. 装配流程:从 config.yaml 到活对象

入口: from_task(task, tasks_dir)(两套框架各有一份,逻辑同构)。

config.yaml
│ prepare_task_config() 读 yaml + 把每个 agent 的子部件造好

task_config(dict,里面 memory/llm/tools/output_parser 已是对象)
│ load_agent() × N agent_registry.build(agent_type, ...)
│ load_environment() env_registry.build(env_type, ...)

Simulation / TaskSolving 实例

prepare_task_config 是装配的重头,它遍历每个 agent 配置,把字符串型的子配置就地替换成实例(initialization.py:96-119):

# initialization.py:96-108(节选)
for agent_configs in task_config["agents"]:
agent_configs["memory"] = load_memory(agent_configs.get("memory", {}))
agent_configs["llm"] = load_llm(agent_configs.get("llm", ...))
agent_configs["memory_manipulator"] = load_memory_manipulator(...)
agent_configs["tools"] = load_tools(agent_configs.get("tools", []))
# output_parser 也按 type 从 registry 造出来

每个 load_xxx 内部都是「pop 出 type 字符串 → 对应 registry.build」的同一套路(如 load_environment,initialization.py:59-61)。

解题框架的特殊一步: 它把 agents 装进按角色索引的字典,并按 cnt_agents 把 critic 深拷贝成多个(tasksolving.py:38-48)——对应 02 章讲的「招募 N 个专家」。

3. Agent:prompt 三段式 + 调 LLM + 解析

所有智能体继承 BaseAgent(agents/base.py:17-31),核心字段是三段 prompt 模板:prepend_prompt_template / prompt_template / append_prompt_template,外加 memoryoutput_parserllm

一次 astep 干三件事(以 SolverAgent 为例,agents/tasksolving_agent/solver.py:30-80):

# 示意,非源码 —— 提炼 solver.py:30-80 的主干
prepend, append, prompt_token = self.get_all_prompts(...) # ① 填两段模板
history = await self.memory.to_messages(..., max_send_token=...) # ② 取裁剪过的历史
response = await self.llm.agenerate_response(prepend, history, append) # ③ 调 LLM
parsed = self.output_parser.parse(response) # ④ 解析成结构化结果

重点看 prompt 的三段式: get_all_promptsstring.Template.safe_substitute${task_description}${role_description} 等占位符填上(agents/base.py:62-80)。中间夹着 history(聊天记忆),所以一次请求是 prepend(身份+任务) + history(对话) + append(输出格式要求) 的三明治。这种切分让「角色设定」「历史」「输出指令」各管一段,YAML 里也是分开写的(见 brainstorming 配置的 *_prepend_prompt / *_append_prompt)。

OutputParser: 把 LLM 的自由文本解析成结构化动作。解析器也走 registry,按任务名注册(output_parser/output_parser.py:40-75),比如 nlp_classroom_3players 要求输出严格的 Action: / Action Input: 两行,不符就抛 OutputParserError 触发 agent 的重试循环(max_retry)。

记忆: 默认 ChatHistoryMemory,conversation 智能体的记忆靠 visibility/updater 组件来写(见 01 章),解题智能体则在 decision_maker 里用 add_message_to_memory 显式写(见 03 章)。

4. LLM 层:OpenAI/本地模型 + 花费统计

llms/openai.py 在 import 时就根据环境变量决定用哪家(openai.py:38-90):OPENAI_API_KEY → OpenAI;AZURE_OPENAI_API_KEY → Azure;VLLM_BASE_URL → 本地 vLLM。本地模型靠探测 server 的 /models 端点自动登记进 LOCAL_LLMS(openai.py:69-90)。

一个贯穿全框架的细节——美元花费统计: BaseAgent.get_spend() 转发到 llm.get_spend()(agents/base.py:53-60),环境的 report_metrics 把所有智能体的花费加总打印(tasksolving_env/basic.py:123-132)。这就是为什么 TaskSolving.run() 结束会把每个角色花了多少钱列出来——对跑大量多智能体实验的研究者很实用。

5. 巧妙之处

  • 27 行的 Registry 撑起整个可插拔架构:所有「换组件就换玩法」的能力,根子上就是这个 register/build 字典(registry.py)。配置驱动 + 装饰器注册,是这套框架最值得带走的骨架。
  • prompt 三段式:把身份/历史/输出指令切成 prepend/history/append,既适配模型的多轮消息格式,又让 YAML 能分段维护 prompt(agents/base.py:62-80)。
  • 花费作为一等公民:每个 agent、每个 LLM 都自带 get_spend,环境层自动汇总(tasksolving_env/basic.py:123-132),把实验成本可观测做进了框架。

6. 边界与局限

  • import 即注册的脆弱性:组件没被 import 就不在 registry 里;__init__.py 的 import 列表是隐式契约,漏一个就「未注册」报错。
  • LLM 层在模块 import 时就读环境变量、探测本地 server(openai.py:38-90),没有 key 只是 logger.warn 不报错,出问题往往延后到第一次调用。
  • get_all_promptscount_string_tokens 依赖 self.llm.args.model,代码注释自己标了 # TODO: not generalizable(agents/base.py:68)——非 OpenAI tokenizer 的模型计数可能不准。

7. 横向对比

AgentVerse 的「Registry + YAML 装配」属于重声明式配置路线:玩法写在 YAML、代码只提供组件库。这和「用 Python 代码直接编排 agent」的命令式框架是两种取向——前者改行为不改码、利于做对照实验(很契合它的研究定位),后者更灵活但每个 demo 都要写胶水代码。模拟/解题两套上层框架(01/02 章)能共用同一个底座,正是因为底座只认「字符串→类」这一个抽象。

8. 代码地图

主题文件符号
注册表(register/build)agentverse/registry.pyRegistry.registerRegistry.build
YAML → 对象装配agentverse/initialization.pyprepare_task_configload_environment
环境注册触发点agentverse/environments/__init__.pyenv_registry(import 列表)
Agent 基类(三段 prompt)agentverse/agents/base.pyBaseAgent.get_all_prompts
一个 agent 的完整 stepagentverse/agents/tasksolving_agent/solver.pySolverAgent.astep
对话 agent(模拟)agentverse/agents/simulation_agent/conversation.pyConversationAgent.astep
LLM 客户端选择 + 花费agentverse/llms/openai.pyDEFAULT_CLIENT_ASYNCget_spend
输出解析(文本→动作)agentverse/output_parser/output_parser.pyOutputParser.parseOutputParserError