共享召回后两条线并发,A 写完仍要等 B:等待落在 gather,也落在图的 super-step 语义里。
两条线同时跑,即使 A 先写完,接口也还要等 B。/v1/dual 要一次交回追问和报告,少一条就凑不齐这次响应。前面做的并行有用,但用户拿到第一句话的时间,仍被较慢的分支拖着。
共享召回,在末尾汇合
这一阶段先把同步双轨接清楚。输入经过一次 rewrite,再召回成 recall_bundle,A/B 共用这批初始资料。A 生成追问,B 生成报告并检查是否继续检索,两份结果在末尾汇合。
按同步接口绘制的示意图,分支长度不代表实测耗时。右侧汇合点仍等待两条线都完成。
这个等待来自图的执行语义。LangGraph 的图 API 文档把执行分成离散的 super-step:同一步里的节点并行,串行的节点分属不同步;所有节点都 inactive、没有消息在途,图才算跑完。所以汇合点本来就要等。
图里只有三个节点
九月整理 backend/dual_graph.py 时,图实际只有 rewrite_once、shared_retrieve 和 run_tracks 三个节点。A/B 的并发放在最后一个节点内部:
consult_task = asyncio.create_task(self.run_consult(state))report_task = asyncio.create_task(self.run_report(state))consult_text, report = await asyncio.gather(consult_task, report_task)所以 B 的每一轮追加检索不是独立的图节点,它在 run_report 函数里循环。画图时可以展开内部步骤来解释,读 trace 时却要知道实际节点到哪为止,不然容易去找一个代码里根本没命名的节点。
官方文档说得很直白:节点和边“不过是函数”,同步异步都行。函数内部的循环对图不可见;只有跨节点的循环,才需要条件边或 Command 的 goto 来表达。
gather 的语义和它的替代
这段 create_task + gather 当时够用,但它的几条语义值得记下。按 Python asyncio 文档:
- 结果顺序和传入顺序一致,所以解包成
consult_text, report是稳的; - 要等全部完成才返回,用户等待由慢的那条决定;
- 一条抛异常会立刻向上传播,但另一条不会被取消,会继续跑完。
同一页还提醒,create_task 的返回值要自己保存引用,事件循环只持有弱引用,否则任务可能中途消失。官方现在更推荐 asyncio.TaskGroup:任一任务出错,它会取消其余任务(gather 不会),最后以 ExceptionGroup 抛出。如果以后要处理“B 炸了 A 是否还该继续”,我会先换成 TaskGroup。
共享召回省了什么,没省什么
共享召回省了一次初始检索,也便于比较两边用了什么材料:初始输入有了共同基准,不必先解释两份起始材料为何不同。不过 B 还可能追加检索,比较最终结果时,要把它后来补进来的证据单独看。
rewrite 只负责把问题整理成适合检索的短句。LangChain 文档里这一步有现成名字,叫 query enhancement:改写不清楚的查询、生成多个变体,或用额外上下文扩展查询。当前图的这个节点读的是 query,不能把后来聊天路径携带的完整历史也算进来;生成节点要用历史,还得显式传入相应 state。
这在机制上也说得通:LangGraph 里 State 的 schema 就是所有节点的输入 schema。节点能看到什么,取决于 state 里放了哪些键。
选 LangGraph,是为了固定读写位置
我选 LangGraph 是想把几个读写位置固定下来:
- rewrite 写出新 query;
- retrieve 写证据;
- run_tracks 写两个结果。
Route 只处理请求和返回,外部调用放 adapter。同步调用用普通函数也能组织,图的用处在于后面可以沿节点检查数据。排查时先看问题改写成了什么,再看召回材料是否回应了这个问题,最后看两条线怎样用材料。节点给出的是检查位置,具体哪里错,仍要读对应的输入和输出。
“固定读写位置”在图里是硬约束
State 的每个键有自己的 reducer,没指定时默认覆盖。如果两个并行节点在同一步写同一个没挂 reducer 的键,LangGraph 会直接报 INVALID_CONCURRENT_GRAPH_UPDATE,理由是不确定该怎么合并。issue #2336 里能看到真实报错:
InvalidUpdateError: At key ‘count_b’: Can receive only one value per step.
维护者给的修法是给那个键加 reducer,比如 Annotated[list, operator.add],代价是它变成只追加。我们的 A、B 各写各的键,所以没碰上;哪天两条线要写同一个键,得先想好 reducer。
“沿节点检查数据”也有官方工具撑着。checkpointer 按 thread_id 把图状态存成 checkpoint;时间旅行可以用 get_state_history 找到某个 checkpoint 再重放,之前的节点不重跑,之后的(含 LLM 调用)会重跑。两个代价也写在文档里:内存版 checkpointer 重启就丢,长对话下 checkpoint 会一直累积。另外子图在父图里只算一个 super-step,这和“读 trace 要知道节点到哪为止”是同一件事:边界由框架定义,不由画图的人定义。
想让 A 先到页面,得换交付方式
只把 gather 前面的两个调用并发起来,用户等的时间还是由慢的那条线决定。LangGraph 文档把并行化列为一种工作流模式,承诺的是提速,没承诺首个结果什么时候到。要让 A 先到页面,下一步得改变接口的交付方式。同步 /v1/dual 先保留给完整流程演示,聊天和报告分别返回的版本另做。
另做的那一版,现成零件至少有三层:
| 层 | 做法 | 要注意的 |
|---|---|---|
| 图 | 流式模式的 updates 每步后流出 state 更新,同一步的多个更新分别流出;messages 按 token 流 LLM 输出;节点内可用 get_stream_writer() 发中间数据 |
输出带节点名,前端按 langgraph_node 分流 |
| 编排 | LlamaIndex Workflows 这类事件驱动框架,step 收发事件,stream_events() 边跑边拿 |
等于换编排模型,不是给接口加个参数 |
| HTTP | FastAPI StreamingResponse 接 async 生成器流式返回 |
async 任务只在 await 处能取消,客户端断开后生成器不一定停得下来 |
如果只按现在的结构改,我会先试图这一层:节点不用动,run_tracks 内部的两条线也能用 get_stream_writer() 各自先吐结果。要是 A、B 拆成两个并行节点,“同一步的多个更新分别流出”就直接对上了。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。