Vendored deer-flow upstream (bytedance/deer-flow) plus prompt-injection hardening: - New deerflow.security package: content_delimiter, html_cleaner, sanitizer (8 layers — invisible chars, control chars, symbols, NFC, PUA, tag chars, horizontal whitespace collapse with newline/tab preservation, length cap) - New deerflow.community.searx package: web_search, web_fetch, image_search backed by a private SearX instance, every external string sanitized and wrapped in <<<EXTERNAL_UNTRUSTED_CONTENT>>> delimiters - All native community web providers (ddg_search, tavily, exa, firecrawl, jina_ai, infoquest, image_search) replaced with hard-fail stubs that raise NativeWebToolDisabledError at import time, so a misconfigured tool.use path fails loud rather than silently falling back to unsanitized output - Native client back-doors (jina_client.py, infoquest_client.py) stubbed too - Native-tool tests quarantined under tests/_disabled_native/ (collect_ignore_glob via local conftest.py) - Sanitizer Layer 7 fix: only collapse horizontal whitespace, preserve newlines and tabs so list/table structure survives - Hardened runtime config.yaml references only the searx-backed tools - Factory overlay (backend/) kept in sync with deer-flow tree as a reference / source See HARDENING.md for the full audit trail and verification steps.
19 KiB
DeerFlow 流式输出设计
本文档解释 DeerFlow 是如何把 LangGraph agent 的事件流端到端送到两类消费者(HTTP 客户端、嵌入式 Python 调用方)的:两条路径为什么必须并存、它们各自的契约是什么、以及设计里那些 non-obvious 的不变式。
TL;DR
- DeerFlow 有两条并行的流式路径:Gateway 路径(async / HTTP SSE / JSON 序列化)服务浏览器和 IM 渠道;DeerFlowClient 路径(sync / in-process / 原生 LangChain 对象)服务 Jupyter、脚本、测试。它们无法合并——消费者模型不同。
- 两条路径都从
create_agent()工厂出发,核心都是订阅 LangGraph 的stream_mode=["values", "messages", "custom"]。values是节点级 state 快照,messages是 LLM token 级 delta,custom是显式StreamWriter事件。这三种模式不是详细程度的梯度,是三个独立的事件源,要 token 流就必须显式订阅messages。 - 嵌入式 client 为每个
stream()调用维护三个set[str]:seen_ids/streamed_ids/counted_usage_ids。三者看起来相似但管理三个独立的不变式,不能合并。
为什么有两条流式路径
两条路径服务的消费者模型根本不同:
| 维度 | Gateway 路径 | DeerFlowClient 路径 |
|---|---|---|
| 入口 | FastAPI /runs/stream endpoint |
DeerFlowClient.stream(message) |
| 触发层 | runtime/runs/worker.py::run_agent |
packages/harness/deerflow/client.py::DeerFlowClient.stream |
| 执行模型 | async def + agent.astream() |
sync generator + agent.stream() |
| 事件传输 | StreamBridge(asyncio Queue)+ sse_consumer |
直接 yield |
| 序列化 | serialize(chunk) → 纯 JSON dict,匹配 LangGraph Platform wire 格式 |
StreamEvent.data,携带原生 LangChain 对象 |
| 消费者 | 前端 useStream React hook、飞书/Slack/Telegram channel、LangGraph SDK 客户端 |
Jupyter notebook、集成测试、内部 Python 脚本 |
| 生命周期管理 | RunManager:run_id 跟踪、disconnect 语义、multitask 策略、heartbeat |
无;函数返回即结束 |
| 断连恢复 | Last-Event-ID SSE 重连 |
无需要 |
两条路径的存在是 DRY 的刻意妥协:Gateway 的全部基础设施(async + Queue + JSON + RunManager)都是为了跨网络边界把事件送给 HTTP 消费者。当生产者(agent)和消费者(Python 调用栈)在同一个进程时,这整套东西都是纯开销。
为什么不能让 DeerFlowClient 复用 Gateway
曾经考虑过三种复用方案,都被否决:
-
让
client.stream()变成async def client.astream()
breaking change。用户用不上的async for/asyncio.run()要硬塞进 Jupyter notebook 和同步脚本。DeerFlowClient 的一大卖点("把 agent 当普通函数调用")直接消失。 -
在
client.stream()内部起一个独立事件循环线程,用StreamBridge在 sync/async 之间做桥接
引入线程池、队列、信号量。为了"消除重复",把复杂度代替代码行数引进来。是典型的"wrong abstraction"——开销高于复用收益。 -
让
run_agent自己兼容 sync mode
给 Gateway 加一条用不到的死分支,污染 worker.py 的焦点。
所以两条路径的事件处理逻辑会相似但不共享。这是刻意设计,不是疏忽。
LangGraph stream_mode 三层语义
LangGraph 的 agent.stream(stream_mode=[...]) 是多路复用接口:一次订阅多个 mode,每个 mode 是一个独立的事件源。三种核心 mode:
flowchart LR
classDef values fill:#B8C5D1,stroke:#5A6B7A,color:#2C3E50
classDef messages fill:#C9B8A8,stroke:#7A6B5A,color:#2C3E50
classDef custom fill:#B5C4B1,stroke:#5A7A5A,color:#2C3E50
subgraph LG["LangGraph agent graph"]
direction TB
Node1["node: LLM call"]
Node2["node: tool call"]
Node3["node: reducer"]
end
LG -->|"每个节点完成后"| V["values: 完整 state 快照"]
Node1 -->|"LLM 每产生一个 token"| M["messages: (AIMessageChunk, meta)"]
Node1 -->|"StreamWriter.write()"| C["custom: 任意 dict"]
class V values
class M messages
class C custom
| Mode | 发射时机 | Payload | 粒度 |
|---|---|---|---|
values |
每个 graph 节点完成后 | 完整 state dict(title、messages、artifacts) | 节点级 |
messages |
LLM 每次 yield 一个 chunk;tool 节点完成时 | (AIMessageChunk | ToolMessage, metadata_dict) |
token 级 |
custom |
用户代码显式调用 StreamWriter.write() |
任意 dict | 应用定义 |
两套命名的由来
同一件事在三个协议层有三个名字:
Application HTTP / SSE LangGraph Graph
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ frontend │ │ LangGraph │ │ agent.astream│
│ useStream │──"messages- │ Platform SDK │──"messages"──│ graph.astream│
│ Feishu IM │ tuple"──────│ HTTP wire │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
- Graph 层(
agent.stream/agent.astream):LangGraph Python 直接 API,mode 叫"messages"。 - Platform SDK 层(
langgraph-sdkHTTP client):跨进程 HTTP 契约,mode 叫"messages-tuple"。 - Gateway worker 显式做翻译:
if m == "messages-tuple": lg_modes.append("messages")(runtime/runs/worker.py:117-121)。
后果:DeerFlowClient.stream() 直接调 agent.stream()(Graph 层),所以必须传 "messages"。app/channels/manager.py 通过 langgraph-sdk 走 HTTP SDK,所以传 "messages-tuple"。这两个字符串不能互相替代,也不能抽成"一个共享常量"——它们是不同协议层的 type alias,共享只会让某一层说不是它母语的话。
Gateway 路径:async + HTTP SSE
sequenceDiagram
participant Client as HTTP Client
participant API as FastAPI<br/>thread_runs.py
participant Svc as services.py<br/>start_run
participant Worker as worker.py<br/>run_agent (async)
participant Bridge as StreamBridge<br/>(asyncio.Queue)
participant Agent as LangGraph<br/>agent.astream
participant SSE as sse_consumer
Client->>API: POST /runs/stream
API->>Svc: start_run(body)
Svc->>Bridge: create bridge
Svc->>Worker: asyncio.create_task(run_agent(...))
Svc-->>API: StreamingResponse(sse_consumer)
API-->>Client: event-stream opens
par worker (producer)
Worker->>Agent: astream(stream_mode=lg_modes)
loop 每个 chunk
Agent-->>Worker: (mode, chunk)
Worker->>Bridge: publish(run_id, event, serialize(chunk))
end
Worker->>Bridge: publish_end(run_id)
and sse_consumer (consumer)
SSE->>Bridge: subscribe(run_id)
loop 每个 event
Bridge-->>SSE: StreamEvent
SSE-->>Client: "event: <name>\ndata: <json>\n\n"
end
end
关键组件:
runtime/runs/worker.py::run_agent— 在asyncio.Task里跑agent.astream(),把每个 chunk 通过serialize(chunk, mode=mode)转成 JSON,再bridge.publish()。runtime/stream_bridge— 抽象 Queue。publish/subscribe解耦生产者和消费者,支持Last-Event-ID重连、心跳、多订阅者 fan-out。app/gateway/services.py::sse_consumer— 从 bridge 订阅,格式化为 SSE wire 帧。runtime/serialization.py::serialize— mode-aware 序列化;messagesmode 下serialize_messages_tuple把(chunk, metadata)转成[chunk.model_dump(), metadata]。
StreamBridge 的存在价值:当生产者(run_agent 任务)和消费者(HTTP 连接)在不同的 asyncio task 里运行时,需要一个可以跨 task 传递事件的中介。Queue 同时还承担断连重连的 buffer 和多订阅者的 fan-out。
DeerFlowClient 路径:sync + in-process
sequenceDiagram
participant User as Python caller
participant Client as DeerFlowClient.stream
participant Agent as LangGraph<br/>agent.stream (sync)
User->>Client: for event in client.stream("hi"):
Client->>Agent: stream(stream_mode=["values","messages","custom"])
loop 每个 chunk
Agent-->>Client: (mode, chunk)
Client->>Client: 分发 mode<br/>构建 StreamEvent
Client-->>User: yield StreamEvent
end
Client-->>User: yield StreamEvent(type="end")
对比之下,sync 路径的每个环节都是显著更少的移动部件:
- 没有
RunManager—— 一次stream()调用对应一次生命周期,无需 run_id。 - 没有
StreamBridge—— 直接yield,生产和消费在同一个 Python 调用栈,不需要跨 task 中介。 - 没有 JSON 序列化 ——
StreamEvent.data直接装原生 LangChain 对象(AIMessage.content、usage_metadata的UsageMetadataTypedDict)。Jupyter 用户拿到的是真正的类型,不是匿名 dict。 - 没有 asyncio —— 调用者可以直接
for event in ...,不必写async for。
消费语义:delta vs cumulative
LangGraph messages mode 给出的是 delta:每个 AIMessageChunk.content 只包含这一次新 yield 的 token,不是从头的累计文本。
这个语义和 LangChain 的 fs2 Stream 风格一致:上游发增量,下游负责累加。Gateway 路径里前端 useStream React hook 自己维护累加器;DeerFlowClient 路径里 chat() 方法替调用者做累加。
DeerFlowClient.chat() 的 O(n) 累加器
chunks: dict[str, list[str]] = {}
last_id: str = ""
for event in self.stream(message, thread_id=thread_id, **kwargs):
if event.type == "messages-tuple" and event.data.get("type") == "ai":
msg_id = event.data.get("id") or ""
delta = event.data.get("content", "")
if delta:
chunks.setdefault(msg_id, []).append(delta)
last_id = msg_id
return "".join(chunks.get(last_id, ()))
为什么不是 buffers[id] = buffers.get(id,"") + delta:CPython 的字符串 in-place concat 优化仅在 refcount=1 且 LHS 是 local name 时生效;这里字符串存在 dict 里被 reassign,优化失效,每次都是 O(n) 拷贝 → 总体 O(n²)。实测 50 KB / 5000 chunk 的回复要 100-300ms 纯拷贝开销。用 list + "".join() 是 O(n)。
三个 id set 为什么不能合并
DeerFlowClient.stream() 在一次调用生命周期内维护三个 set[str]:
seen_ids: set[str] = set() # values 路径内部 dedup
streamed_ids: set[str] = set() # messages → values 跨模式 dedup
counted_usage_ids: set[str] = set() # usage_metadata 幂等计数
乍看像是"三份几乎一样的东西",实际每个管不同的不变式。
| Set | 负责的不变式 | 被谁填充 | 被谁查询 |
|---|---|---|---|
seen_ids |
连续两个 values 快照里同一条 message 只生成一个 messages-tuple 事件 |
values 分支每处理一条消息就加入 | values 分支处理下一条消息前检查 |
streamed_ids |
如果一条消息已经通过 messages 模式 token 级流过,values 快照到达时不要再合成一次完整 messages-tuple |
messages 分支每发一个 AI/tool 事件就加入 | values 分支看到消息时检查 |
counted_usage_ids |
同一个 usage_metadata 在 messages 末尾 chunk 和 values 快照的 final AIMessage 里各带一份,累计总量只算一次 |
_account_usage() 每次接受 usage 就加入 |
_account_usage() 每次调用时检查 |
为什么不能只用一个 set
关键观察:同一个 message id 在这三个 set 里的加入时机不同。
sequenceDiagram
participant M as messages mode
participant V as values mode
participant SS as streamed_ids
participant SU as counted_usage_ids
participant SE as seen_ids
Note over M: 第一个 AI text chunk 到达
M->>SS: add(msg_id)
Note over M: 最后一个 chunk 带 usage
M->>SU: add(msg_id)
Note over V: snapshot 到达,包含同一条 AI message
V->>SE: add(msg_id)
V->>SS: 查询 → 已存在,跳过文本合成
V->>SU: 查询 → 已存在,不重复计数
seen_ids永远在 values 快照到达时加入,所以它是 "values 已处理" 的标记。一条只出现在 messages 流里的消息(罕见但可能),seen_ids里永远没有它。streamed_ids在 messages 流的第一个有效事件时加入。一条只通过 values 快照到达的非 AI 消息(HumanMessage、被 truncate 的 tool 消息),streamed_ids里永远没有它。counted_usage_ids只在看到非空usage_metadata时加入。一条完全没有 usage 的消息(tool message、错误消息)永远不会进去。
集合包含关系:counted_usage_ids ⊆ (streamed_ids ∪ seen_ids) 大致成立,但不是严格子集,因为一条消息可以在 messages 模式流完 text 但在最后那个带 usage 的 chunk 之前就被 values snapshot 赶上——此时它已经在 streamed_ids 里,但还不在 counted_usage_ids 里。把它们合并成一个 dict-of-flags 会让这个微妙的时序依赖从类型系统里消失,变成注释里的一句话。三个独立的 set 把不变式显式化了:每个 set 名对应一个可以口头回答的问题。
端到端:一次真实对话的事件时序
假设调用 client.stream("Count from 1 to 15"),LLM 给出 "one\ntwo\n...\nfifteen"(88 字符),tokenizer 把它拆成 ~35 个 BPE chunk。下面是事件到达序列的精简版:
sequenceDiagram
participant U as User
participant C as DeerFlowClient
participant A as LangGraph<br/>agent.stream
U->>C: stream("Count ... 15")
C->>A: stream(mode=["values","messages","custom"])
A-->>C: ("values", {messages: [HumanMessage]})
C-->>U: StreamEvent(type="values", ...)
Note over A,C: LLM 开始 yield token
loop 35 次,约 476ms
A-->>C: ("messages", (AIMessageChunk(content="ele"), meta))
C->>C: streamed_ids.add(ai-1)
C-->>U: StreamEvent(type="messages-tuple",<br/>data={type:ai, content:"ele", id:ai-1})
end
Note over A: LLM finish_reason=stop,最后一个 chunk 带 usage
A-->>C: ("messages", (AIMessageChunk(content="", usage_metadata={...}), meta))
C->>C: counted_usage_ids.add(ai-1)<br/>(无文本,不 yield)
A-->>C: ("values", {messages: [..., AIMessage(complete)]})
C->>C: ai-1 in streamed_ids → 跳过合成
C->>C: 捕获 usage (已在 counted_usage_ids,no-op)
C-->>U: StreamEvent(type="values", ...)
C-->>U: StreamEvent(type="end", data={usage:{...}})
关键观察:
- 用户看到 35 个 messages-tuple 事件,跨越约 476ms,每个事件带一个 token delta 和同一个
id=ai-1。 - 最后一个
values快照里的AIMessage不会再触发一个完整的messages-tuple事件——因为ai-1 in streamed_ids跳过了合成。 end事件里的usage正好等于那一份 cumulative usage,不是它的两倍——counted_usage_ids在 messages 末尾 chunk 上已经吸收了,values 分支的重复访问是 no-op。- 消费者拿到的
content是增量:"ele" 只包含 3 个字符,不是 "one\ntwo\n...ele"。想要完整文本要按id累加,chat()已经帮你做了。
为什么这个设计容易出 bug,以及测试策略
本文档的直接起因是 bytedance/deer-flow#1969:DeerFlowClient.stream() 原本只订阅 ["values", "custom"],漏了 "messages"。结果 client.stream("hello") 等价于一次性返回,视觉上和 chat() 没区别。
这类 bug 有三个结构性原因:
- 多协议层命名:
messages/messages-tuple/ HTTP SSEmessages是同一概念的三个名字。在其中一层出错不会在另外两层报错。 - 多消费者模型:Gateway 和 DeerFlowClient 是两套独立实现,没有单一的"订阅哪些 mode"的 single source of truth。前者订阅对了不代表后者也订阅对了。
- mock 测试绕开了真实路径:老测试用
agent.stream.return_value = iter([dict_chunk, ...])喂 values 形状的 dict 模拟 state 快照。这样构造的输入永远不会进入messagesmode 分支,所以即使stream_mode里少一个元素,CI 依然全绿。
防御手段
真正的防线是显式断言 "messages" mode 被订阅 + 用真实 chunk shape mock:
# tests/test_client.py::test_messages_mode_emits_token_deltas
agent.stream.return_value = iter([
("messages", (AIMessageChunk(content="Hel", id="ai-1"), {})),
("messages", (AIMessageChunk(content="lo ", id="ai-1"), {})),
("messages", (AIMessageChunk(content="world!", id="ai-1"), {})),
("values", {"messages": [HumanMessage(...), AIMessage(content="Hello world!", id="ai-1")]}),
])
# ...
assert [e.data["content"] for e in ai_text_events] == ["Hel", "lo ", "world!"]
assert len(ai_text_events) == 3 # values snapshot must NOT re-synthesize
assert "messages" in agent.stream.call_args.kwargs["stream_mode"]
为什么这比"抽一个共享常量"更有效:共享常量只能保证"用它的人写对字符串",但新增消费者的人可能根本不知道常量在哪。行为断言强制任何改动都要穿过实际执行路径,改回 ["values", "custom"] 会立刻让 assert "messages" in ... 失败。
活体信号:BPE 子词边界
回归的最终验证是让真实 LLM 数 1-15,然后看是否能在输出里看到 tokenizer 的子词切分:
[5.460s] 'ele' / 'ven' eleven 被拆成两个 token
[5.508s] 'tw' / 'elve' twelve 拆两个
[5.568s] 'th' / 'irteen' thirteen 拆两个
[5.623s] 'four'/ 'teen' fourteen 拆两个
[5.677s] 'f' / 'if' / 'teen' fifteen 拆三个
子词切分是 tokenizer 的外部事实,无法伪造。能看到它就说明数据流逐 chunk 地穿过了整条管道,没有被任何中间层缓冲成整段。这种"活体信号"在流式系统里是比单元测试更高置信度的证据。
相关源码定位
| 关心什么 | 看这里 |
|---|---|
| DeerFlowClient 嵌入式流 | packages/harness/deerflow/client.py::DeerFlowClient.stream |
chat() 的 delta 累加器 |
packages/harness/deerflow/client.py::DeerFlowClient.chat |
| Gateway async 流 | packages/harness/deerflow/runtime/runs/worker.py::run_agent |
| HTTP SSE 帧输出 | app/gateway/services.py::sse_consumer / format_sse |
| 序列化到 wire 格式 | packages/harness/deerflow/runtime/serialization.py |
| LangGraph mode 命名翻译 | packages/harness/deerflow/runtime/runs/worker.py:117-121 |
| 飞书渠道的增量卡片更新 | app/channels/manager.py::_handle_streaming_chat |
| Channels 自带的 delta/cumulative 防御性累加 | app/channels/manager.py::_merge_stream_text |
| Frontend useStream 支持的 mode 集合 | frontend/src/core/api/stream-mode.ts |
| 核心回归测试 | backend/tests/test_client.py::TestStream::test_messages_mode_emits_token_deltas |