跳转到内容
快速开始

③ 问答

Agent 类型

DeepAgent 与 BaseAgent:tool loop 在谁那边运行、各自怎么组织一轮对话,以及它们的 provider、prompt 和工具怎么配

随包只有两种 agent 运行时,而它们的区别不在体量,而在tool loop 在谁那边运行。两者读取的是 同一份工具注册表,应答的也是同一个 POST /api/chat

DeepAgent:引擎在你的部署中运行BaseAgent:由你的模型来调用
tool loop 运行在这里,你的部署里LLM 服务商那边,通过 HTTP 调用 /mcp
骨架LangChain create_agent + deepagents 中间件栈没有,故意的
虚拟文件系统有,由 PostgreSQL 支撑没有
代码执行eval(进程内 JavaScript REPL)没有
Agent Skills有,走 SkillsMiddleware没有
图表一段 vis-chart 围栏块,由客户端渲染没有,由消费它的客户端自带
provider 配置键PROVIDERS_DEEPPROVIDERS_BASE
什么时候用默认选择,没有特别理由就用它你的目标是 Claude Desktop、Cursor、ChatGPT Apps 或任意 MCP 客户端

DeepAgent 是主力:读文件、做计算、多步工具调用都在它这边。BaseAgent 则刻意做成 MCP 工具面 之上最薄的一层派生,其能力与外部 MCP 客户端所能获得的能力完全一致:凡是 BaseAgent 独力 做不到的事,外部 MCP 客户端也一样做不到。

agent 的发现方式和工具一样:启动时扫目录。

  1. AGENT_DIRS 里的每个目录都被扫一遍 .py 文件 子目录、以及以 _ 开头的文件名都跳过
  2. 一个类只要定义了 generate_response 就成为 agent 类名去掉结尾的 Agent 就是 agent 名,如 DeepAgentDeep
  3. 如果它同时定义了 load_llm_clients,此刻就会执行 PROVIDERS_{NAME} 为输入,每个 provider 条目建一个 client
  4. 一个 client 都没有的 agent 不会被提供 所以把 PROVIDERS_BASE 留空,就是关掉 BaseAgent 的办法
  5. GET /api/models 返回活下来的 agent/provider 组合 POST /api/chatagentprovider 作为两个字段分别接收
Terminal window
curl http://localhost:18080/api/models
response
{"success":true,"code":0,"msg":"ok","data":["Base/gemini-2.5-flash","Deep/claude-sonnet","Deep/gemini-3.5-flash"]}

也就是说两半都由客户端选。不传 provider 时,DeepAgent 回退到 DEFAULT_PROVIDER_DEEP, 该项未设则回退到 gemini-3.5-flash;BaseAgent 回退到 gemini-2.5-flash

DeepAgent 每轮用四项输入即时组装一个 deepagents agent:发现到的工具、一份 system prompt、 一个由 PostgreSQL 支撑的文件系统,以及一层中间件栈。

对已认证用户来说,后端是一个五挂载点的 CompositeBackenddeepagents 原生的文件 工具(lsread_filewrite_fileedit_fileglobgrep)把它当成一块磁盘 那样操作:

挂载点作用域权限放什么
(默认)本会话读写本轮的草稿空间
/memories/跨会话读写agent 自己决定留下的笔记
/uploads/本会话只读本次请求附带的文件
/library/跨会话只读该用户更早解析过的文件
/skills/随包只读来自 SKILL_DIRS 的 Agent Skills,agent 绝不允许改自己的 skill

/uploads//library/ 是从 th_files 表以指针形式镜像过来的,没有任何字节被复制进 PostgreSQL。解析出的文本会内联进来,好让 grep 能用;原始字节则在模型读该文件时以多模态 形式呈现。匿名调用获得的是内存版 StateBackend,因此什么都不会留下。

一轮 DeepAgent 被六个中间件包住,按施加顺序:

  1. ToolFaultMiddleware 最外层,所以一个抛异常的工具会被兜住,而不是把这一轮直接终结。
  2. InvalidToolCallRepairMiddleware JSON 未能解析成功的工具调用会被修复,而不是被丢弃。
  3. ModelCallLimitMiddleware 真正的每轮预算:MODEL_CALL_LIMIT 次模型调用(默认 50),到了就优雅收尾。RECURSION_LIMIT 只是 LangGraph 的原始上限,留作兜底。
  4. CodeInterpreterMiddleware 来自 langchain-quickjs:一个持久的进程内 JavaScript REPL,以 eval 暴露出来。包缺失时该中间件带一条 warning 被跳过,这一轮就没有它。
  5. SkillsMiddleware 启动时把每个 skill 的 frontmatter 注入提示词,只在任务需要时才通过 /skills/ 挂载点取全文 SKILL.md。匿名会话没有可读的挂载点,因此跳过。
  6. UniversalPromptCachingMiddleware(ttl="5m") 最后一个,所以它的判断说了算。在支持缓存的 provider 上把提示词标记为可缓存,在不支持的上被忽略。

deepagents 本来会附带、但这里刻意不要的有两样:

  • 没有 task 子代理。 流式转发子代理会把所有事件压到它结束之后才吐,而且在这里 task 永远只是一个空转的自我克隆。关掉它是通过注册 harness profile,而不是通过 DISALLOWED_TOOLS_DEEP
  • 没有 write_todos deepagents 0.7 把 TodoListMiddleware 从默认栈里去掉了, DeepAgent 也没有把它加回来。

delete 这个文件工具同样被按名字排除:PostgreSQL 文件系统后端没有实现它,因此该工具未 包含在工具面内。

BaseAgent 没有 LangChain agent 循环、没有中间件、没有虚拟文件系统。它渲染一份 prompt,把 我们的 MCP server 交给 provider,然后把结果转发出去。tool loop 归 provider 所有。

准入规则: BaseAgent 的 client 只收 Responses 风格的 API,因为它的全部意义就在于把我们的 MCP server 交给别人的 agent loop。只提供 Chat Completions 的 provider,改由 DeepAgent 的 LangChain 栈来消费:在这里再写一个本地 function-call 循环,只是重复劳动。

Provider默认模型服务端 MCP有状态
OpenAI ResponsesOpenAIResponsesClientgpt-5-nano支持
Gemini InteractionsGeminiClientgemini-2.5-flash暂用本地 function 兜底
DeepSeek ResponsesDeepSeekResponsesClientdeepseek-v4-flash不支持,只有 function 与服务端 web_search
DashScope ResponsesDashScopeClientqwen3.5-flash不支持:它的 MCP 只接受 server_protocol: "sse",我们是 streamable HTTP

选它之前还有两件事值得先知道:

  • 它默认只保留最近五个用户轮次的历史(user_message_threshold)。
  • 它的 provider 条目不带 llm_type。client 类是由provider 键名的前缀决定的: gemini…gpt…deepseek…qwen…/dashscope…。键名没有匹配上任何已知前缀,就悄悄地 拿不到 client,该 provider 也随之从 /api/models 消失。

同一个问题由不同 agent 应答时,产出的形式不同。

DeepAgentBaseAgent
由谁产出模型在正文里直接写一段纯数据 JSON 的 vis-chart 围栏块不产出
由谁渲染Web 客户端,用该块里的 JSON 渲染成可交互图消费它的那个 MCP 客户端自带的可视化
服务端往返没有:不调工具,也不生成 PNG不适用

这些工具本身见内置工具

每个 agent 读自己那个 PROVIDERS_ 键,后缀是 agent 名字的大写形式:

config.localdb.yaml
PROVIDERS_DEEP:
gemini-3.5-flash:
llm_type: google-genai
api_key: GOOGLE_API_KEY
model: gemini-3.5-flash
temperature: 1.0
claude-sonnet:
llm_type: openai
api_key: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
model: anthropic/claude-sonnet-4.6
temperature: 0.1
qwen-vl:
llm_type: openai
api_key: DASHSCOPE_API_KEY
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-vl-max
supports_pdf: true
supports_image: true

每个条目的键就是客户端要请求的 provider 名。条目内部:

llm_type string default: openai

要初始化哪个 LangChain 集成:google-genaiopenai。凡是通过 OpenAI 兼容端点访问的 (OpenRouter、DashScope、火山引擎)都是 openai 再加一个 base_url

api_key string

是保存密钥的那个键的名字,不是密钥本身。 见下面的警告。

base_url string

OpenAI 兼容 provider 的端点。直接写字面 URL 可以;写一个能在环境变量或配置里解析成 URL 的 名字也接受。

model string required

上游的 model id。没有它的条目会在启动时带一条 warning 被跳过。

temperature number

原样透传,此处没列出的其它键也一样:include_thoughtsthinking_level 之类会不加改动 地送进 LangChain 的构造函数。

supports_pdf boolean

只在 OpenAI 兼容端点上需要声明,因为引擎推断不出它们的能力。true 表示以原生文件块发送 PDF;不设则以预先抽取的文本提供。

supports_image boolean

同上,用于图片。

api_key: GOOGLE_API_KEY
$GOOGLE_API_KEY,否则查配置键 GOOGLE_API_KEY
api_key: sk-abc123…
被当成名字去查,什么也没查到

PROMPTS_{NAME} 是一串 Jinja2 模板路径。每一项都可以用 路径@键名 后缀钉住该模板注册时用 的键名;不写后缀,键名就是文件名去掉 .jinja

config.yaml
PROMPTS_DEEP:
- agent/prompts/deep.jinja

DeepAgent 按请求里带的 prompt_name 选模板,选不到就回退到配置里的第一个。通过用户 prompt 接口存下的用户级 prompt 优先于两者。

和别处一样的那两个键,后缀是 agent 名字的大写形式:

配置键作用
ALLOWED_TOOLS_{NAME}白名单:一旦设置,就只提供这些工具
DISALLOWED_TOOLS_{NAME}黑名单:在白名单之后生效,因此总是它说了算

发行版 config.yaml 把它们统统留空,也就是「全部被发现的工具」。它另外还带着 ALLOWED_TOOLS_RTC / DISALLOWED_TOOLS_RTC / PROMPTS_RTC 这样的占位项,那是一个已被删除 的 agent 留下的空键:无害,也提醒你那个后缀不过是个 agent 名字。

AGENT_DIRS 的扫描方式和 MCP_TOOL_DIRS 一样,默认只有一条:包内的 mirobody/agent。把你自己的目录加上,并列在最前面:

config.{env}.yaml
AGENT_DIRS:
- agents # 你的,先扫
- mirobody/agent # 包内默认

约定很小:一个 agent 一个类,一个叫 generate_response 的异步生成器;如果这个 agent 带 provider,还要有一个把它的 PROVIDERS_ 条目变成 client 的 load_llm_clients。 继承 DeepAgent 就能把两者一起继承下来。

agents/triage_agent.py
from typing import Any, AsyncGenerator
from mirobody.agent.deep_agent import DeepAgent
class TriageAgent(DeepAgent):
"""Registers as agent name "Triage"; configured with PROVIDERS_TRIAGE,
ALLOWED_TOOLS_TRIAGE, DISALLOWED_TOOLS_TRIAGE and PROMPTS_TRIAGE."""
async def generate_response(
self, user_id: str, messages: list[Any], **kwargs
) -> AsyncGenerator[dict[str, Any], None]:
async for event in super().generate_response(user_id, messages, **kwargs):
yield event