DeerFlow2.0 源码解读笔记之agent核心篇
先看核心代码,factory.py的create_deerflow_agent(看如何创建一个agent)
create_deerflow_agent篇
参数列表之章
他的参数列表是这样的
def create_deerflow_agent(
model: BaseChatModel,
tools: list[BaseTool] | None = None,
*,
system_prompt: str | None = None,
middleware: list[AgentMiddleware] | None = None,
features: RuntimeFeatures | None = None,
extra_middleware: list[AgentMiddleware] | None = None,
plan_mode: bool = False,
state_schema: type | None = None,
checkpoint_channel_mode: CheckpointChannelMode = "full",
checkpoint_snapshot_frequency: int | None = None,
checkpointer: BaseCheckpointSaver | None = None,
name: str = "default",
subagent_runtime: SubagentRuntime | None = None,
pii_redaction_config: PiiRedactionConfig | None = None,
) -> CompiledStateGraph:
10多个参数,我们挨个掰扯
前四个参数
前四个只要是个人都知道,我们跳过
第五、六、七个参数----中间件
middleware,中间件,类型是langchain的AgentMiddleware列表,这里普及一下,中间件是langraph的一种设计,他其实也是面向切面设计的一种理念,把需要在agent或者每次model调用前后执行的超时重试啊,记录日志啊,权限检查,记录执行结果啊之类的操作,统一写到middleware里,然后统一配置。而langchain的AgentMidlleware呢,就是langchain自个儿的一个实现了。
但是,这个middleware在这个函数的参数场景中还有别的含义,那就是,全量定义使用的中间件。
deerflow自己是定制了一套轮椅中间件方案的,那就是第六个参数,features,我们看到他的类型RuntimeFeatures,可以看到它的结构
sandbox: bool | AgentMiddleware = True
memory: bool | AgentMiddleware = False
# Explicit memory config for direct create_deerflow_agent(features=...) callers.
# The lead-agent AppConfig path passes resolved_app_config.memory directly.
memory_config: MemoryConfig | None = None
summarization: Literal[False] | AgentMiddleware = False
subagent: bool | AgentMiddleware = False
vision: bool | AgentMiddleware = False
auto_title: bool | AgentMiddleware = False
guardrail: Literal[False] | AgentMiddleware = False
loop_detection: bool | AgentMiddleware = True
token_budget: bool | AgentMiddleware = False
可以看到它自己实现了一堆沙盒啊,记忆啊,摘要啊,子agent啊,视觉处理啊,自动标题命名啊,安全护栏啊,循环检查啊,token预算啊等等一系列标准中间件,你只要把他们传入成True就能使用它自己设计的默认中间件,还可以自己传入自己实现的中间件做参数,这样就会默认用你传入的这个中间件,可以说非常灵活。
但是,即使默认中间件们(features)已经能解决困扰我们的绝大部分问题,但是,毕竟可能还是有一些特殊场景需要用到特定中间件,这个时候就需要引入我们的extra_middleware,额外传入中间件
因此我们这里看到两个机制,全部用自己手搓的中间件的设计(middleware),以及使用deerflow自己提供的轮椅(features+extra_middleware),这两者是互斥的,并不能同时使用
第八个参数,plan_mode
可以看到他是布尔类型的参数啊,这个用来配置是否使用TodoMiddleware,也就是我们平时使用codex啊,dsh啊之类看到的那个agent会在复杂工作下给自己布置任务的那个任务模式,整一堆任务列表的那个
比如冻住大象
todo:打开冰箱
todo:把大象放进冰箱
todo:关闭冰箱门
就是整这么个任务列表
第九个参数,state_schema
众所周知,state是langraph规定的agent的运行时保存的状态。那state_schema就表示它的状态结构,一般我们通过TypedDict来定义它。不过嘛,deer_flow自己有一套默认的ThreadState实现这个运行时状态结构定义(我们待会儿讲这个)。
第十、十一、十二个参数
checkpoint是langraph的一个设计,表示某个执行边界的状态快照,存储图的状态,消息历史,下个执行节点啥的。可用来做原生多轮对话(每次同一个对话传入同一个thread_id),还有查看历史,通过checkpoint回溯过去的状态等。
而checkepointer就是checkpoint的存取组件,比如保存到内存,sqlite,postgresql之类的,负责检查点的持久化
提到了checktpointer就不得不提到这里的checkpoint_channel_mode。channel是langraph提供的管理状态字段及其更新的机制,比如消息字段不断接收新消息,普通 字符串字段用覆盖更新
deerflow呢,就提供了full和delta两种模式,full是默认的完整值兼容模式,Delta则允许相应通道通过增量写入和周期性快照管理状态。
第十三,十四,十五个参数
name就是agent名称。
subagent_runtime是deerflow提供的一套机制,可以让多个Agent图能够共享同一个子任务执行容量,并在需要时创建,启动,停止批处理服务。具体机制以后讲
这个pii_readaction_config就是字面意思,个人身份信息(电话身份证号等)进行隐藏,替换,过滤等的配置
函数体之章
我们看到函数体本身
if middleware is not None and features is not None:
raise ValueError("Cannot specify both 'middleware' and 'features'. Use one or the other.")
if checkpoint_channel_mode == "delta" and checkpointer is not None:
raise ValueError(
"create_deerflow_agent does not support checkpoint_channel_mode='delta' with a checkpointer: "
"persisted graphs built here bypass checkpoint mode marker injection and the fail-closed "
"compatibility gate (see deerflow.runtime.checkpoint_mode), so a mixed-mode store would "
"silently corrupt thread state. Use the guarded application paths (make_lead_agent or "
"DeerFlowClient) for delta persistence; delta without a checkpointer is ephemeral and allowed."
)
if middleware is not None and extra_middleware:
raise ValueError("Cannot use 'extra_middleware' with 'middleware' (full takeover).")
if subagent_runtime is not None and (middleware is not None or features is None or features.subagent is False):
raise ValueError("subagent_runtime requires features.subagent to be enabled; it cannot be used with middleware full takeover")
if subagent_runtime is not None and subagent_runtime.batch_config is not None and subagent_runtime.batch_submitter is None:
raise RuntimeError("The explicit durable batch worker is not running; await subagent_runtime.start() or enter it with 'async with' before calling create_deerflow_agent")
if extra_middleware:
for mw in extra_middleware:
if not isinstance(mw, AgentMiddleware):
raise TypeError(f"extra_middleware items must be AgentMiddleware instances, got {type(mw).__name__}")
effective_tools: list[BaseTool] = list(tools or [])
effective_state = get_thread_state_schema(checkpoint_channel_mode, checkpoint_snapshot_frequency) if state_schema is None else adapt_state_schema_for_mode(state_schema, checkpoint_channel_mode, checkpoint_snapshot_frequency)
if middleware is not None:
effective_middleware = list(middleware)
else:
feat = features or RuntimeFeatures()
effective_middleware, extra_tools = _assemble_from_features(
feat,
name=name,
plan_mode=plan_mode,
extra_middleware=extra_middleware or [],
subagent_runtime=subagent_runtime,
pii_redaction_config=pii_redaction_config,
)
# Deduplicate by tool name — user-provided tools take priority.
existing_names = {t.name for t in effective_tools}
for t in extra_tools:
if t.name not in existing_names:
effective_tools.append(t)
existing_names.add(t.name)
effective_middleware = normalize_middleware_state_schemas(
effective_middleware,
checkpoint_channel_mode,
checkpoint_snapshot_frequency,
)
return create_agent(
model=model,
tools=effective_tools or None,
middleware=effective_middleware,
system_prompt=system_prompt,
state_schema=effective_state,
checkpointer=checkpointer,
name=name,
)
可以看到,整个create_deerflow_agent函数的代码,大概可以分成六个阶段,参数校验阶段,准备工具列表与state schema确定阶段middleware组装阶段,合并工具阶段,state_schema规范阶段,agent图创建阶段
参数校验阶段
1.检查Middleware与Features是否兼容
前面我们提到,Middleware是完全接管,完全使用自己的中间件,而features + extra_middleware是复用deerflow的默认实现+额外部分中间件,这两者是互斥的
因此第一步是检查Middleware和features是否兼容
if middleware is not None and features is not None:
raise ValueError("Cannot specify both 'middleware' and 'features'. Use one or the other.")
2.检查delta模式能否使用checkpointer
源码有明确的注释指出
checkpoint_channel_mode:
Checkpoint representation for accumulating channels. Defaults to the
full-state compatibility schema. ``"delta"`` requires the guarded
persistence paths (mode markers + compatibility gate) and is therefore
rejected when combined with *checkpointer* in this factory; without a
checkpointer the graph is ephemeral and delta is allowed.
让Gemini翻译一下,意思就是
checkpoint_channel_mode:
用于累积通道(accumulating channels)的检查点表示形式。默认为全状态兼容模式(full-state compatibility schema)。"delta" 需要受保护的持久化路径(模式标记 + 兼容性门禁),因此在此工厂中与 checkpointer 结合使用时会被拒绝;而在没有 checkpointer 的情况下,图是临时的(ephemeral),此时允许使用 delta。
因此这里delta模式直接禁用了checkpointer,然后
if checkpoint_channel_mode == "delta" and checkpointer is not None:
raise ValueError(
"create_deerflow_agent does not support checkpoint_channel_mode='delta' with a checkpointer: "
"persisted graphs built here bypass checkpoint mode marker injection and the fail-closed "
"compatibility gate (see deerflow.runtime.checkpoint_mode), so a mixed-mode store would "
"silently corrupt thread state. Use the guarded application paths (make_lead_agent or "
"DeerFlowClient) for delta persistence; delta without a checkpointer is ephemeral and allowed."
)
3.检查中间件是否与完全接管模式冲突
第一步检查的是middleware和features,这一步检查的是middleware和extra_middleware
if middleware is not None and extra_middleware:
raise ValueError("Cannot use 'extra_middleware' with 'middleware' (full takeover).")
4.检查subagent的使用条件
因为这个subagent_runtime是和subagent配套使用的,没有subagent那他的存在本身就没有意义,而subagent是在features里配的,middleware又和features互斥
if subagent_runtime is not None and (middleware is not None or features is None or features.subagent is False):
raise ValueError("subagent_runtime requires features.subagent to be enabled; it cannot be used with middleware full takeover")
5.检查批处理运行时是否启动
这个以后再说,看代码吧
if subagent_runtime is not None and subagent_runtime.batch_config is not None and subagent_runtime.batch_submitter is None:
raise RuntimeError("The explicit durable batch worker is not running; await subagent_runtime.start() or enter it with 'async with' before calling create_deerflow_agent")
6.检查扩展中间件类型
如果不是AgentMiddleware的实例,就抛出typeError
if extra_middleware:
for mw in extra_middleware:
if not isinstance(mw, AgentMiddleware):
raise TypeError(
"extra_middleware items must be "
"AgentMiddleware instances"
)
这个时候就有小伙伴问了,为什么不用pydantic检查类型啊。很简单,因为pydantic的类型检查只适用于纯数据类,而这个create_deerflow_agent传入的对象是BaseModel,是AgentMiddleware,是checkpointer,是大型运行时引擎对象,pydantic对第三方蕾进行反射解析时非常沉重,且他默认喜欢深拷贝或重建对象,会严重破坏性能
这个时候还有小伙伴问了,凭什么就你extra_middleware可以类型检查啊,我其他都不用做吗,那是因为函数结尾调用的是langraph底层的create_agent,他自己就做了类型检查,我们就不用脱裤子放屁了。而extra_middleware撑不到最后就完蛋了,所以他要单独做一个类型检查。
阶段二:准备工具列表,确定State Schema
首先
effective_tools: list[BaseTool] = list(tools or [])
创建新列表是为了避免浅拷贝改变原来参数传入的列表
然后确定状态结构
effective_state = get_thread_state_schema(checkpoint_channel_mode, checkpoint_snapshot_frequency) if state_schema is None else adapt_state_schema_for_mode(state_schema, checkpoint_channel_mode, checkpoint_snapshot_frequency)
阶段三,决定由谁来组装Middleware
如果是middleware存在,那就直接复制整个列表
否则,那就组装features和extra_middleware
- 先检查features是否提供,如果没有提供,就创建默认配置
- _assemble_from_features装配组件
if middleware is not None:
effective_middleware = list(middleware)
else:
feat = features or RuntimeFeatures()
effective_middleware, extra_tools = _assemble_from_features(
feat,
name=name,
plan_mode=plan_mode,
extra_middleware=extra_middleware or [],
subagent_runtime=subagent_runtime,
pii_redaction_config=pii_redaction_config,
)
阶段四,合并工具,解决名称冲突
这里得到的extra_tools是实际启用的features里的tools,因为features的中间件及工具是动态启用的,所以这里extra_tools返回的就是这个被选中为true或者被传入实现的features里配的工具。
因此这一步要做的就是把我们传入项目的工具tools和features配的工具做个按名称去重合并
思路就是先拿到传入create_deerflow函数的参数的tools的名称列表,然后挨个比对最终加入名称
existing_names = {t.name for t in effective_tools}
for t in extra_tools:
if t.name not in existing_names:
effective_tools.append(t)
existing_names.add(t.name)
阶段五,规范化Middleware的State Schema
做这一步的主要原因是有的Middleware自己可能声明state_schema,可能会和effective_state冲突
effective_middleware = normalize_middleware_state_schemas(
effective_middleware,
checkpoint_channel_mode,
checkpoint_snapshot_frequency,
)
他的逻辑大概是,如果是full模式,直接返回中间件列表的副本,如果是delta模式,遍历所有中间件,如果没有声明状态schema,就原样加入结果列表,如果存在,则执行copy.copy对中间件对象进行浅拷贝,再修改副本的state_schema(为了不对调用者持有的对象造成不必要的修改,这里不直接修改 原始的中间件对象),最后得到规范化后的effective_middleware
阶段六,创建Agent图
执行langchain的create_agent函数
return create_agent(
model=model,
tools=effective_tools or None,
middleware=effective_middleware,
system_prompt=system_prompt,
state_schema=effective_state,
checkpointer=checkpointer,
name=name,
)
补充篇,ThreadState的结构
我们知道,State是langGraph全局共享的数据结构,AgentState是langchain的一种实现
ThreadState则是deerflow的特定实现,他继承了AgentState
class ThreadState(AgentState):
sandbox: SandboxStateField
thread_data: NotRequired[ThreadDataState | None]
title: NotRequired[str | None]
artifacts: Annotated[list[str], merge_artifacts]
todos: Annotated[list | None, merge_todos]
goal: Annotated[GoalState | None, merge_goal]
goal_outcome: NotRequired[GoalOutcomeState | None]
applied_run_inputs: NotRequired[dict | None]
uploaded_files: NotRequired[list[dict] | None]
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> metadata (no base64)
promoted: Annotated[PromotedTools | None, merge_promoted]
delegations: Annotated[list[DelegationEntry], merge_delegations]
skill_context: Annotated[list[SkillEntry], merge_skill_context]
tool_artifacts: Annotated[list[ArtifactEntry], merge_tool_artifacts]
tool_artifact_processed: Annotated[list[str], merge_artifacts]
task_notes: Annotated[dict | None, TaskNotesChannel(dict | None, merge_task_notes)]
task_history: NotRequired[dict | None]
summary_text: NotRequired[str | None]
background_tasks: NotRequired[list[BackgroundTaskState]]
尽管原始的langchain的AgentState已经提供了最基础的状态管理
class AgentState(TypedDict, Generic[ResponseT]):
"""State schema for the agent."""
messages: Required[Annotated[list[AnyMessage], add_messages]]
jump_to: NotRequired[Annotated[JumpTo | None, EphemeralValue, PrivateStateAttr]]
structured_response: NotRequired[Annotated[ResponseT, OmitFromInput]]
他包括了会话上下文messages,动态跳转路由jump_to,结构化输出结果strctured_response
但是维持deerflow所需的是更加庞大的state
我们来一一看看
sandbox
第一个sandbox,他的类型SandboxStateField其实是个类型别名
SandboxStateField = Annotated[NotRequired[SandboxState | None], merge_sandbox]
他是SandboxState的类型别名,加上merge_sandbox这么一个reducer。
这里不得不提到langGraph的reducer机制,,那就是,如果Annotated声明的注释(第二个参数)是一个函数(也就是reducer),当每个state writers/channel wirters(图节点,工具,中间件钩子方法,外部api注入等)返回时,框架的缓冲区就会暂存状态,当langgraph会在他的每个superstep(Node,middleware钩子,Tool Command)等执行完毕时,就会调用reducer,一般做数据的合并操作。
一般这种写法用在state身上,不论是threadstate,还是继承了threadstate的什么类,还是中间件自己的state.
那这个SandboxState又是什么呢
我们看到他的源码
class SandboxState(TypedDict):
sandbox_id: NotRequired[str | None]
没错,他就是只有sandbox_id这么一个东西,也就是当前线程(这里的线程其实是会话的唯一标识,不是那个操作系统的线程)关联的沙箱。
为什么 他需要reducer呢
这里我们的reducer也就是merge_sandbox有一句注释声明
"""Reducer for sandbox state - accepts idempotent writes only.
Multiple sandbox tools can initialize lazily in the same graph step and
emit the same sandbox_id via Command(update=...). LangGraph needs an
explicit reducer for that shared state key. Different sandbox ids in the
same thread indicate a lifecycle/isolation bug, so fail closed instead of
choosing one silently.
"""
让Gemini直译
“用于沙箱状态的归约器(Reducer)—— 仅接受幂等写入。
多个沙箱工具可以在同一个图步骤(graph step)中延迟初始化,并通过 Command(update=...) 提交相同的 sandbox_id。LangGraph 需要为这个共享的状态键(state key)配置一个显式的归约器。如果同一个线程(thread)中出现了不同的沙箱 ID,则表明存在生命周期/隔离性漏洞(bug),因此要采取故障闭锁(fail closed)策略直接报错拦截,而不是静默地随便选择其中一个。”
也就是说这个reducer是为了幂等。
我们想象两个场景,一个是Deerflow的懒初始化机制并不会创建的时候一次性拿沙箱,而是在工具第一次读写文件的操作时才开始创建沙箱。
我们假设同一个超步里大模型并发派发了两个工具调用,那么他们去申请,一个沙箱id 888,一个沙箱id 999。而在LangGraph里,同一个Step收到对同一个Key的两次并发写入,会直接抛出InvalidUpdateError直接崩溃。(这里的key其实指的是字典里的key概念,而AgentState,乃至ThreadState都继承了TypedDict这个类,也就是说,他们本身就是字典,他们的字段本身就是key)。
我们来看到这个reducer,merge_sandbox的逻辑
def merge_sandbox(existing: SandboxState | None, new: SandboxState | None) -> SandboxState | None:
"""Reducer for sandbox state - accepts idempotent writes only.
Multiple sandbox tools can initialize lazily in the same graph step and
emit the same sandbox_id via Command(update=...). LangGraph needs an
explicit reducer for that shared state key. Different sandbox ids in the
same thread indicate a lifecycle/isolation bug, so fail closed instead of
choosing one silently.
"""
if new is None:
return existing
if existing is None:
return new
existing_id = existing.get("sandbox_id")
new_id = new.get("sandbox_id")
if existing_id == new_id:
return existing
raise ValueError(f"Conflicting sandbox state updates: {existing_id!r} != {new_id!r}")
他做了四件事,第一步空值容错和放行
先检查旧沙箱状态和新沙箱状态,如果新沙箱状态啥也没有,就直接返回旧沙箱状态,如果旧沙箱是空的,那就直接返回新沙箱状态
第二步,提取沙箱ID
第三步,幂等一致性验证,比较旧沙箱ID和新沙箱ID,如果他们相同旧直接返回旧沙箱状态
第四步,熔断,新旧沙箱ID并不相等,直接抛出ValueError
thread_data
sandbox解决了使用哪个沙箱的问题,而thread_data解决的是这个线程在哪工作的问题
他的类型本质上是这个
class ThreadDataState(TypedDict):
workspace_path: NotRequired[str | None]
uploads_path: NotRequired[str | None]
outputs_path: NotRequired[str | None]
他确定的是文件放的位置,workspacce_path指向线程工作目录,uploads_path指向上传目录,outputs_path指向输出目录
title
本质会话标题,TitleMiddleware负责生成标题,title负责接收生成的哪个标题
artifacts
生成的文件
他保存的是一个字符串列表
我们前面知道,workspace_path是存放中间临时文件的地方,uploads_path存放用户上传的原始原件的地方,outputs_path是存放输出文件的地方。
deerflow在把文件递交给outputs_path这个正式输出目录之后,就会把这个文件的路径写给artifacts,因此这个字符串列表记录的是当前线程的输出文件路径。而前端监听到artifacts列表有新文件时,也会把新文件渲染弹出来。
todos
看起来很高级,他只有个list,啥也没写list里面传什么类型
这种约束是由于为了防止输入的todo太乱导致的
根据稳健性原则,我们要对自己发送的东西要严格保守,对接收别人的东西要宽容大度
因此,为了防止LLM输出失误,为了防止后期todos实际接收的类型结构有所变动,我们必须这里不声明具体传入什么类型的list
这里的merge_todos很有讲究
我们假设他没有这个merge_todos作为reducer,那么我们的todos在LangGraph底层会生成的通道类为默认通道LastValue(State的每个字段都会编译成一个通道实例),LastValue是在同一个超步内禁止并发的,同一个超步多次写入会抛出异常。而当我们为字段写了个reducer,比如这个todos里的merge_todos,LangGraph就会把todos编译成BinaryOperatorAggregate,也就是原本走默认通道,有了reducer走聚合通道,支持了多次聚合。
现在看到他的代码
if new is None:
return existing
return new
这里明显是为了防止新的传入一个空值,然后导致清空整个todos任务清单,只有传入的不是空值才会真正替换任务清单。
为什么有时候会有传递todos为None的情况呢,因为有的节点返回状态可能就这么写了。返回一个None.所以这里我们让传回来[]才是清空todos,而不是让节点随便返回一个None就能把之前还在做的todos给直接清空。
goal
他的语义就是我们的目标模式,这个不管dsh,codex还是其他什么balabala都有这样的设计
这里我们直接看他的GoalState
class GoalState(TypedDict):
objective: str
status: Literal["active"]
created_at: str
updated_at: str
continuation_count: int
max_continuations: int
no_progress_count: int
max_no_progress_continuations: int
last_evaluation: NotRequired[dict[str, Any]]
objective指的就是我们的目标的信息,比如“完成暑假作业”
status这里只能传入active,表示状态,很明显
这个continuation_count就是我们跑的轮次的动态计数。因为在目标模式下,如果一轮会话没完成,他会自动续期,这个continuation_count就是保存我们当前这个目标跑了多少轮迭代了。这个max_continuations就是这个目标最多自动跑多少个轮次,就不会自动执行了。
no_progress_count就是指的是统计agent连续多少次原地踏步。max_no_progress_continuations就是最大连续踏步的轮次。和上面的continuation_count和max_continuations相似。
这个last_evaluation看似是传入一个dict,其实我们deerflow是有他的数据结构的实现的。
GoualEvalution的结构
class GoalEvaluation(TypedDict):
satisfied: bool
blocker: GoalBlocker
reason: str
evidence_summary: NotRequired[str]
relied_on_assumption: NotRequired[bool]
比如satisfied,判断目标是否完成。blocker,目标没达成的原因的类型
GoalBlocker = Literal[
"none",
"missing_evidence",
"needs_user_input",
"run_failed",
"external_wait",
"goal_not_met_yet",
]
reason是具体的原因信息
evidence_summary,依据汇总,表示agent看过那些文件,得到了什么结论
relied_on_assumption,是否依赖假设。
runtime/goal.py有一句话解释了他的含义
"If the assistant assumed, guessed or substituted for missing or ambiguous information, the goal is not met: use blocker needs_user_input.\n"
if interaction_policy is None or interaction_policy.allows_clarification
else "This run had no user to ask. A low-risk, reversible assumption about a detail the request left open, stated in the final answer, is not by itself a reason to fail the goal. "
……
"Set relied_on_assumption to true only when a satisfied verdict relies on a stated assumption. "
翻译
“如果助手对缺失的或模棱两可的信息进行了假设、猜测或替代,则目标未达成:使用阻碍项 needs_user_input(需要用户输入)。”
“本次运行(run)没有用户可供询问。在最终回答中声明的、针对请求中未明确(留有余地)的细节所做出的低风险且可逆的假设,其本身并不构成判定目标失败的理由。”
“仅当一个满意(合格)的裁决依赖于某个已声明的假设时,才将 relied_on_assumption 设为 true。”
也就是说satisfied为True且relied_on_assumption为为False则完美达成目标
如果relied_on_assumption说明agent干活的时候有做了某些用户没有透露的假设,需要特别声明
如果是有人值守的交互式对话,那么就使用needs_user_input来跟用户交互,跟用户确认
如果是无人值守的后台任务/定时任务,只要风险低,易于修改回滚,已经在回答中声明了这个假设,那目标就算达成了。
goal_outcome
如果说goal是目标模式的运行态,那goal_outcome就是他的完成态,我们看到他的结构
goal作为目标模式的过程,他随着agent的迭代一直在变,当目标完成 的时刻,deerflow会把goal从活跃状态清楚,而在同一个事务里把这次目标的各种元数据快照到goal_outcome里
class GoalOutcomeState(TypedDict):
"""The latest met goal, written only by the checkpoint write that clears it."""
status: Literal["achieved"]
objective: str
goal_created_at: str
achieved_at: str
continuation_count: int
max_continuations: int
reason: str
relied_on_assumption: bool
reply_message_id: str | None
status,标志完成状态
goal_created_at和achieved_at表示目标的创建和完成时间
continuation_count和max_continuations,relied_on_assumption不多赘述
reply_message_id,用来记录哪条AI的最终回复消息真正回答了用户的 问题
applied_run_inputs
applied_run_inputs的存在是为了 防止同一个用户输入指令被执行多次
当Agent处理完一批HumanMessage之后,他会把这些指令 的id放入delivered集合,然后在Agent的状态更新中把这次处理过的所有run_input_id显式写入applied_run_inputs。
applied_run_inputs还有个两阶段确认机制,第一步,把applied_run_inputs写进Checkpoint.确认checkpoint写入成功后,调用acknowledge通知消息队列确认为已处理,把这些指令删除
此外呢,我们还能注意到他没写reducer.之所以不需要reducer是因为他通过checkpoint机制强同步的。每次新输入被处理,input_control就会给他产生一个新的更新后的applied_run_inputs。
uploaded_files
uploaded_files存储的是本轮会话新上传的文件列表
他的机制(UploadsMiddleware的相关代码)如下
每轮对话开始时,UploadsMiddleware把检查用户发的HumanMessage,是否有带新文件,如果有新文件,则把文件提取大纲和预览,塞进用户发的消息的current_uploads字段里,然后把这批新文件写入ThreadState的uploaded_files里。如果这轮没有新文件,则直接把uploaded_files清空。
这时候有小伙伴肯定想知道了,明明current_uploads已经有这个文件了,那为什么还需要他单独存一个uploaded_files
场景A.子代理可以通过state看到这个uploaded_files,而不能看到主Agent的current_uploads,如果需要子代理能访问用户上传的文件,这显然是必要的
场景B.工具调用list_uploaded_files工具,查看当前运行时所有上传的文件,如果遍历对话历史的HumanMessage的current_uploads显然太慢
场景C.Agent崩溃后需要重新初始化运行环境,需要从ThreadState中把uploaded_files读出来,以此知道磁盘上挂载了哪些用户文件
因此他的存在的确是必要的。
viewed_images
我们想象这样一种场景,agent每次上下文如果我们都传入完整的图片,那么token的消耗无疑是巨大的,甚至是容易挤爆上下文窗口的,当checkpoint尝试存储的时候,每轮会话的checkpoint都会尝试保存,checkpoint的数据库将会迅速膨胀,每一步都在保存checkpoint,每一步都在读写同一个图片数据,也会造成Agent系统的卡顿。因此我们现在需要设计一种模式,让agent将看过的图片不传入完整的图片本身,只传入之前已经得到的图片描述和分析结果。
他的结构是这样的
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> metadata (no base64)
作为一个字典类型,他的key存储图片的物理路径,value的存储类型ViewdImageData
class ViewedImageData(TypedDict):
"""Metadata for a viewed image file.
Only lightweight metadata is persisted in checkpoint state. Image bytes are
read on-demand from the active sandbox or from a synchronized host copy whose
size and SHA-256 match the previously viewed bytes. This avoids duplicating
large base64 payloads across every checkpoint (see #4138).
"""
mime_type: str
size: int
actual_path: str
sha256: str
source_sandbox_id: NotRequired[str]
blob_ref: NotRequired[BlobRefData]
mime_type,记录图片的类型,是PNG,JPEG还是WEBP.我们模型可以根据这个头信息决定用哪种解码器处理图像数据
size,表示图片的字节大小。actual_path表示图片在当前沙箱中的绝对位置
sha256用来验证是否图片是否有在本地被篡改,如果从actual_path读取到的图片的SHA-256和这个字段存储的不一样,那么系统就会报错
source_sandbox_id用来记录沙箱ID
blob_ref,是Deerflow的一种卸载机制,如果图片太大,或者需要在不同线程/不同存储后端之间迁移,他就会存储一个blob_ref,这样ViewdImageData就不需要存放真正的二进制内容,只需要存储一个引用ID然后拿着它去云存储里面捞就可以了
promoted
它的直译就是提拔,提升
我们想象一个场景:一个agent有上百个股票,天气,抖音,原神,GitHub,Hanime等工具,如果每个工具都一次性全量塞进agent的prompt里,那么上下文消耗无疑是巨大的
因此deerflow有内置的工具延迟加载机制
默认情况下,大模型只看得到几个基础核心工具,比如tool_search,bash等。
如果用户让agent去github找哪个PR,LLM发现手里没这个工具,就调用tool_search找github工具,找到之后就把他提拔到当前大模型的工具列表里,这样我们的上下文就有这个工具了。
讲讲promoted这个工具结构
class PromotedTools(TypedDict):
catalog_hash: str
names: list[str]
turn_id: NotRequired[str]
第一,catalog_hash,目录哈希?何意味啊?没错,他存储的是当前 所用的工具库的哈希待会儿我们看reducer的时候再讲用处
第二,names,存储我们这次提拔的工具的名称列表
第三,turn_id,会话轮次的id,待会儿reducer会用到
看到我们的reducer
def merge_promoted(existing: PromotedTools | None, new: PromotedTools | None) -> PromotedTools | None:
"""Reducer for deferred-tool promotions, scoped by catalog and user turn.
- new None/empty -> preserve existing (node didn't touch promotions).
- catalog_hash or explicit turn_id changed -> replace wholesale, dropping
stale names.
- same scope -> union names, dedupe, preserve order.
- a missing new turn_id inherits the existing scope. ``tool_search`` writes
this shape after the filter establishes the current turn before the model.
"""
if not new:
return existing
new_turn_id = new.get("turn_id")
replace = existing is None or existing.get("catalog_hash") != new["catalog_hash"] or (new_turn_id is not None and existing.get("turn_id") != new_turn_id)
existing_names = existing.get("names") if existing is not None else None
if not isinstance(existing_names, list) or not all(isinstance(name, str) for name in existing_names):
existing_names = []
names = new["names"] if replace else existing_names + new["names"]
merged: PromotedTools = {
"catalog_hash": new["catalog_hash"],
"names": list(dict.fromkeys(names)),
}
turn_id = new_turn_id if new_turn_id is not None else (existing or {}).get("turn_id")
if turn_id is not None:
merged["turn_id"] = turn_id
return merged
看到注释,就是这个函数的核心思路。
翻译如下
"""
针对延迟工具提升(deferred-tool promotions)的归约器(Reducer),其作用域由目录(catalog)和用户轮次(user turn)限定。
新入参为 None 或空 -> 保留现有值(表明当前节点未对工具提升进行任何修改)。
catalog_hash 或显式的 turn_id 发生变化 -> 进行全量替换,丢弃过时的名称。
作用域相同 -> 对名称取并集,进行去重,并保持原有顺序。
若新数据中缺失 turn_id,则继承现有的作用域。tool_search 会在过滤器(filter)确立当前轮次之后、进入模型(model)之前,写入这种数据格式(shape)。
"""
也就是说,第一,检查参数为None或者空,这个我们已经讲过了
第二,检查catalog_hash,turn_id是否发生变化,也就是我们的工具库的哈希和轮次的ID检查是否发生过改变,如果改变,说明换代了,直接丢弃所有旧工具,全部替换
第三,同一个轮次内,如果搜出了新工具,那就把新旧工具名单合并,去重,保持原有顺序
第四,如果新写入没带轮次的id(turn_id),那就自动继承旧的turn_id
我们来欣赏一下
if not new:
return existing
new_turn_id = new.get("turn_id")
这里处理空值问题
replace = (
existing is None
or existing.get("catalog_hash") != new["catalog_hash"]
or (new_turn_id is not None and existing.get("turn_id") != new_turn_id)
)
这里判断什么时候把旧工具全量替换,第一,如果existing为空的,根本没初始化
第二,catalog_hash前后不一致,工具库被更新过,哈希变了,那就把旧的全部去掉,更新
第三,轮次id不为空且轮次id改变,即我们开启了新一轮的会话,显式传入了新的轮次id,触发替换全部工具
这三种场景下已有工具清单必须全部抛弃/替换掉
existing_names = existing.get("names") if existing is not None else None
if not isinstance(existing_names, list) or not all(isinstance(name, str) for name in existing_names):
existing_names = []
这里做数据清洗工作,代码写得很细节
先获取这个names,一般都是list[str]。但是万一传入了什么畸形的数据,比如变成,或者包含非字符串,None等情况
names = new["names"] if replace else existing_names + new["names"]
merged: PromotedTools = {
"catalog_hash": new["catalog_hash"],
"names": list(dict.fromkeys(names)),
}
这里做合并去重,同时保证顺序不变
如果replace为True,names直接采用new["names"],丢掉旧的,即直接替换成新的
如果replace为False,用existing_names + new["names"]做列表拼接
用list(dict.fromkeys())利用字典键有序的特性实现O(N)复杂度的保序去重
turn_id = new_turn_id if new_turn_id is not None else (existing or {}).get("turn_id")
if turn_id is not None:
merged["turn_id"] = turn_id
return merged
这里处理新写入不带turn_id的场景。
为什么会有的新写入不带turn_id呢。
真实情况是这样的。每轮会话开始时,由中间件DeferredToolFilterMiddleware写入catalog_hash,names,turn_id等情况,把当轮的turn_id写进去,但是呢模型调用内置工具,比如tool_search,他根本就不会返回turn_id,这根本不是他会关注,也不是他应该关注的点,他顶多返回一下catalog_hash和names,所以这个工具的写入到达Reducer时,new_turn_id就是None。
我们这段代码就是先处理他是None的情况,不是None就用新ID,是None就沿用旧ID
比较细节的是这个(existing or {}).get("turn_id")
if turn_id is not None:
这个是为了防止第一轮进去的时候就没有turn_id的情况,所以existing也是None,因此这里写的是(existing or {}).get("turn_id").因此这里必须有一个条件判断,turn_id新的计算后的turn_id到底是不是还是None(这意味着existing为None).非空才会直接写merge["turn_id"]
为什么会出现existing为空的情况,在以下情况完全有可能,比如多个中间件的执行交错,导致DeferredToolFilterMiddleware写turn_id之前,就有其他中间件开始跑了;比如历史旧数据迁移,后端升级之后,DeferredToolFilterMiddleware在某些场景根本不触发(比如什么定时任务啊,健康检查啊)导致老用户本来没有turn_id,新的turn_id也没启动.比如有外部API直接注入进来不经过DeferredToolFilterMiddleware的before_model
delegations
这个单词的英文意思为委托,委派
他是多Agent系统的记录子任务的完成情况的“账本"
我们看到他的字段声明
delegations: Annotated[list[DelegationEntry], merge_delegations]
看来他是个DelegationEntry的list
看到
DelegationEntry
class DelegationEntry(TypedDict):
id: str
run_id: NotRequired[str]
description: str
subagent_type: str
status: str
result_brief: NotRequired[str]
result_sha256: NotRequired[str]
result_ref: NotRequired[str]
# Why a guardrail cap ended the run early (#3875 Phase 2): token_capped /
# turn_capped / loop_capped. The status stays completed/failed; this field
# is the additive signal that distinguishes a capped run from a clean one.
stop_reason: NotRequired[str]
# RFC #4651 PR2: parent-side citation-check verdict (advisory execution
# evidence), stamped at task write-back; absent on legacy history.
receipt_verdict: NotRequired[dict]
# RFC #4651 PR4: deterministic acceptance-checklist verdict, same
# provenance as receipt_verdict.
acceptance_verdict: NotRequired[dict]
created_at: str
id,为这个子任务的唯一标识符
run_id属于哪一次运行,这里的run_id和id的区别
我们看到这个注释
Provider ids can repeat in
later runs of the same thread.
即
“在同一线程的后续运行(或执行)中,提供者 ID(Provider ids)可能会重复出现。”
也就是说我们原本的这个id,是由LLM提供商提供的ID,他在同一个线程的不同的run(运行)中,是可能会发生重复的。这就导致我们无法区分子任务的具体ID.因此我们字段里的id是LLM提供商生成的那个工具调用的ID。run_id代表的是哪次执行的run.一次这个工具调用生成一个子任务。也就是id为子任务的唯一标识符。
声明一下run和turn的区别。turn(轮次)是一个宏观的概念,用户发一句话,直到他发下一句话之前都可以叫一个轮次,但是run,是LangGraph状态图从Start到Terminal/End,算一次运行(run)
description,任务简介
subagent_type,委派给哪种subagent
他内置了默认 两种 subagent,general-purpose(全能型)和bash(命令行专精型)
同时deerflow的subagent支持扩展模式,支持用户自己实现并注册新的子代理类型。
因此这里用str来支持可扩展性,而不是普通的Enum或者Literal
status,状态
subagent被划分为两个生命周期阶段(中间态和终态)
中间态包括pending(排队等待)和in progress(干活中)
终态包括completed(正常完成),failed(执行失败),cancelled(被人类或系统取消),timed_out(执行总时间超时),polling_timed_out(主代理轮询超时)
非常值得一提的是这个subagent的状态设计,终态是写死的SubagentStatusValue = Literal["completed", "failed", "cancelled", "timed_out", "polling_timed_out"]
但是中间态不是写死的,并且内部的时候状态还是用的str
为什么呢,因为SubagentStatusValue作为终态是需要交付给前端,写到网络协议里的。因此python端必须写死Literal,前端也必须写对应的才能渲染出来。
而内部需要掌管subagent全生命周期状态的status的str则一定程度上允许中间态的扩容乃至容错
result_brief,result_sha256,result_ref
result_brief,子Agent的成果摘要
result_sha256,防止掉包篡改的
result_ref,子Agent的原始成果的指针
这主要是由于子Agent的原始成果可能长篇大论,吃太多主Agent的token,然后就直接让主agent看到这个摘要,如果需要找问题的话再通过result_ref找到这个原始消息
stop_reason 停止原因。如果我们每次因为网络,token额度耗尽,步骤轮数耗尽等问题都把status标记为failed,那么我们之前所做的很多工作可能都会被丢弃,乃至白费。因此这里我们采用的是把尽可能多的做了很多工作的场景标记为success,并记录他们的stop_reason。这样虽然跑一半就停了,好歹能拿到结果
stop_reason有几个取值
分别是token_capped(触发token预算上限),turn_capped(触发对话轮次上限),loop_capped(触发死循环检测),model_length_capped(触发单词大模型最大输出长度限制)
主Agent看到如果stop_reason为空,就直接采纳结果,如果不是空,他可以有复用半成品的result,缩小范围重试,提高预算重新派发三个选项来做决策
receipt_verdict和acceptance_verdict
这俩直译也挺抽象的,一个收据判决,一个接受判决
这俩主要是为了防止子agent编造工作摸鱼
他的机制是这样的
子Agent每次调用工具,系统给他一个带编号的发票/收据,比如[r1 read_file],子Agent写最终总结报告时必须把这个发票/收据像参考文献一样写进receipt_verdict,然后系统就会验收他写的工具调用的编号是不是编的,如果是是编的,那就会标记他给主Agent。最终生成的判决就记录在receipt_verdict里。
acceptance_checks是第二道检测机制,receipt_verdict只是验收发票编号是否伪造,是否把发票编号带给了其他工具,是否一张发票都没有,而acceptance_checks检测的是发票开的文件是否真实存在,是否是合法json,命令的测试是否成功退出0
skill_context
skill_context解决的是上下文存skill的问题,首先,一般的skill,整个SKILL.md都非常的大,更何况有多个SKILL,一次上下文压缩就得全玩完
然后,有的SKILL需要密码或者API Key才能用
因此我们的skill_context是这样的
skill_context: Annotated[list[SkillEntry], merge_skill_context]
看到这个SkillEntry
class SkillEntry(TypedDict):
name: str
path: str
description: str
loaded_at: int
name:技能的名字
path:技能在沙箱内的物理路径
description:技能摘要,最多五百字符
loaded_at:什么时候加载的,其实就是这条消息在messages列表下标的index
注释里有一行
"loaded_at is observational only because message indices reset after compaction."
loaded_at进攻人眼观察,日志排查使用,因为消息压缩之后会index会重置
merge_skill_context这个reducer是个LRU,限制最多同时激活8个技能,然后LRU刷新保活
tool_artifacts
这个工具交付为了解决的问题呢,是工具调用的产出物可能非常大,比如一个500字符以上的文件路径或者URL,因此使用它来做个句柄,句柄本身比较短,大模型后续调用工具的时候直接传入这个句柄,会有中间件拦截把他解析成正常的原本的超长文件路径或URL
我们看到它的结构
tool_artifacts: Annotated[list[ArtifactEntry], merge_tool_artifacts]
class ArtifactEntry(TypedDict):
"""A captured artifact reference from a tool result (issue #4676).
Stored in ``ThreadState.tool_artifacts`` so it survives context compaction.
The model sees only the short ``handle``; the real reference (path, URL,
task id) lives here and is resolved at tool-call time.
"""
handle: str
tool_name: str
tool_call_id: str
call_index: int
artifact_type: str
display_name: str
real_ref: str
mime_type: NotRequired[str]
created_at: str
consumed_by: NotRequired[list[str]]
handle是句柄的这个具体名称,用来填充到上下文的那个字段
tool_name是产出它的工具名称
tool_call_id是对应的工具调用的id,LLM provider给的
call_index,当tool_call_id相同时,对call_index进行累加
artifact_type 工件类型,比如file,url,image
display_name 展示名字
real_ref 真实引用地址
mime_type 文件格式,比如application/pdf,image/png之类的
consumed_by,被谁消费过,存储使用过这个工件的tool_call_id列表。为什么要有它,主要是为了解决重复消费问题,防止同一个工件被重复消费,同一个文件被重复处理。
tool_artifact_processed
现在我们已经有了tool_artifacts这个字段设计,现在我们需要把他给每次发消息时归档,通过我们的ArtifactCaptureMiddleware这个中间件来捕获ToolMessage的产物装进tool_artifacts里并做consumed_by标记
这个中间件使用的是before_model而不是wrap_tool_call
我们注意到有这样一句注释
"""Middleware that captures tool-result artifacts...
Runs as a before_model hook (not wrap_tool_call)
so it never wraps a ToolMessage in a Command.
"""
即
"""捕获工具执行结果产物(artifacts)的中间件...
作为before_model钩子运行(而非wrap_tool_call),
因此它绝不会将ToolMessage包装在Command中。
"""
"it is a before_model hook reading state messages, so it sees the normalized results stored in state regardless of its wrap position."
即
"它是一个读取状态消息的 before_model 钩子,因此无论其包装位置如何,都能看到存储在 state 中的规范化结果。"
"The ledger is independent of registry retention and survives checkpoint reload, graph recreation and compaction."
即
“该账本(ledger)独立于注册表的保留策略,并且在检查点重新加载、图重建以及上下文压缩后依然会保留下来。”
使用before_model解决了wrap_tool_call三个问题,问题A.封装ToolMessage为Command,变成了侵入式包装。后续中间件修改就必须拆包重写再装包。问题B.漏处理,如果 工具调用链路中途在某些中间件短路,驳回或者修改,wrap_tool_call往往处于这些拦截器外层,无法可见。问题C.wrap_tool_call的执行依赖于工具执行完的事件触发瞬间。但如果执行完没来得及写进state时崩溃了,就会导致事件丢失。比如SummarizationMiddleware把消息删掉了,wrap_tool_call产生的数据没存好,就无法找回了
但是before_model带来了一个新的问题,那就是ArtifactCaptureMiddleware的before_model钩子只能全量扫描已有的ToolMessage。因此我们需要引入tool_artifact_processed,来存储已经扫描过的事件的唯一标识(这个判断甚至是O(1))。防止重复地读大量的已经处理过的ToolMessage造成大量的性能浪费。
它的结构大概长这样
[
"result:5f13b58dd1a4b80d077c5cd756f3c8e2f999a38a04e30c117b58b7b4d89dfced",
"result:a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9",
"result:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"call:9a9db4ce1396249f6b557f20886e61cfadc67a5599173999d512141979089ada"
]
在每次判断ToolMessage是否被消费过的时候就可以直接把这个list[str]转set,这样就可以通过in来O(1)找了
前面那个是kind,有result(ToolMessage已经被扫描),call(这条AIMessage里的工具调用已经被扫描),retry:前缀(标记消费重试)
后面那一串就是事件标识,也就是它那个用SHA-256算出的哈希值
task_notes
他是用来记录任务当前的执行状态,遗留问题或者后续行动计划的
简单来说就是agent做任务过程中做的笔记
task_history
此task非彼task,这个task_history的设计之初是为了解决上下文压缩的问题。上下文压缩之后,已有上下文的各种引用会被删除,但是如果我们想看历史原文怎么办。其实压缩的时候原文有持久化保存到SQLite里,task_history则是SQLite相关表的映射
summary_text
上下文压缩的摘要
background_tasks
先看声明
background_tasks: NotRequired[list[BackgroundTaskState]]
它的结构很简单,就是backgroud_task的列表
class BackgroundTaskState(TypedDict):
task_id: str
task_name: str
status: str
updated_at: str
我们看到 它的这个结构
id,名称,状态,更新于
这个task并不是我们常规给主Agent布置的任务,而是脱离主Agent模型循环的长运行任务,也就是他是 后台任务。Deerflow里只有MCP会走这个backgroud_tasks,其他工具的基本走的是内部沙箱

浙公网安备 33010602011771号