diff --git a/README.md b/README.md
index e3456f3..3838e98 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,18 @@ Chat Completions API,`EXTRA_BODY` 中的关闭推理等扩展参数照常透
模型调用继承图的 callbacks、metadata 和 tags,并标记 `llm.complete` / `llm.stream`
及 `llm_attempt`。这是追踪集成的基础,日志、存储和整轮 SSE 的观测需分别接入。
+事故采集使用 LangGraph 原生并行分支:`collect` 生成回复,
+`extract_accident_fields` 提取字段,二者经汇合边进入 `merge_collect`。
+两个分支读取同一轮的参考时间和状态,各写独立通道,合并后统一提交。
+人员核实的抽取依赖本轮回复,保持串行依赖。
+
+增量输出由 LangChain 原生 `astream_events(version="v2")` 消费:
+`RunnableGenerator` 输出通过阶段前缀校验的文本;`validated_reply` 的
+`on_chain_end` 表示整条回复已通过校验,立即刷新分句器最后一句。
+抽取模型的事件不转成聊天正文;`formUpdate` 和 `done` 仍等待业务提交成功。
+客户端关闭流时由原生事件流取消并等待图任务,公开 SSE 事件类型不变。
+节点职责、事件映射和提交时序见 [docs/agent-streaming.md](docs/agent-streaming.md)。
+
## HTTP 接口
- `GET /`:存活检查。
diff --git a/docs/agent-streaming.md b/docs/agent-streaming.md
new file mode 100644
index 0000000..75bc0fe
--- /dev/null
+++ b/docs/agent-streaming.md
@@ -0,0 +1,82 @@
+# LangGraph 原生并行与流式输出
+
+文本模型使用 ChatOpenAI,业务图嵌入 FastAPI。对外仍使用原来的
+stage_code / text_delta / formUpdate / done / error SSE 契约。
+
+## 图内并行
+
+事故采集阶段:
+
+```mermaid
+flowchart LR
+ P[normalize_input] --> R[collect:生成并校验回复]
+ P --> X[extract_accident_fields:提取候选字段]
+ R --> M[merge_collect]
+ X --> M
+ M --> C[应用服务事务提交]
+```
+
+条件边只在采集阶段选中两个分支;welcome、拍照、人员核实走自己的分支。
+通过 `add_edge(["collect", "extract_accident_fields"], "merge_collect")`
+保证两个分支都完成后,汇合节点只运行一次。
+
+`normalize_input` 固定服务器参考时间并清空本轮临时通道。回复节点只更新
+回复、阶段和对话簿记;提取节点只写 `extracted_accident_fields`;汇合节点
+更新 accident_fields / turn_patch,再清空候选通道。两条分支都读取本轮开始时
+的快照,不需要为并发写同一字段增加合并 reducer。
+
+人员核实的提取提示词当前依赖本轮模型回复,并负责声明人员切换。
+本次保留它的串行依赖与提示词,仍能在抽取完成前发送完整回复。
+
+## 原生流与校验
+
+后端使用 LangChain `astream_events(version="v2")`,其生命周期管理负责
+消费期间的任务调度,以及流关闭后的取消与等待。
+
+| 原生事件 | 条件 | 应用服务适配 |
+|---|---|---|
+| on_chain_stream | name=validated_reply_tokens | TextDelta |
+| on_chain_end | name=validated_reply | ReplyFinished,刷新分句器 |
+| on_chain_end | 根 accident_turn 正常结束 | 已提交结果,可发送 FormUpdate |
+
+`validated_reply_tokens` 是原生 RunnableGenerator,封装现有模型文本迭代器,
+等待完整合法的 state 前缀后才产出第一段文本。原始 ChatOpenAI 的 model 事件
+仍可被 callbacks 追踪,但公开文本只消费受控回复 Runnable 的事件。
+原生 messages 流包含校验前 token,单纯按模型或节点过滤不能替代前缀校验。
+
+`validated_reply` 是原生 RunnableLambda。完整回复通过格式、阶段转换和可选
+时间依据校验后,其正常 on_chain_end 才表示回复结束。模型结束事件本身
+不能证明这些业务校验通过,因此不作为刷新最后一句的依据。
+
+字段提取保留 complete / ainvoke,显式 stream=False,避免事件追踪 callback
+把该调用隐式切换为流式。辅助模型输出不会映射为 TextDelta。
+
+## 回复结束与事务完成
+
+ReplyFinished 仅是后端中立适配层的内部标记,不新增公开 SSE 事件。
+它立即刷新 useTextChunk 的剩余完整回复,覆盖有/无字段差量和
+needFormUpdate=false 的情况。
+
+文本可以早于提取和业务事务完成到达客户端;FormUpdate 与 done 仍在事务
+成功后出现。完整回复失败、图失败、业务提交失败时,发送 error 而不发送 done。
+已经输出的文字不可撤回,客户端仍以 done 判断本轮成功。
+
+取消流时必须等图分支与模型关闭后才释放会话锁。持久化模式继续从业务提交
+快照恢复;并行分支产生的未提交 checkpoint 不成为业务成功状态。
+
+## 验证
+
+`test/backends/test_native_parallel_streaming.py` 使用事件屏障验证:
+
+- 回复和抽取同时运行,汇合与提交等待两者完成。
+- 抽取阻塞时已经收到完整正文和 ReplyFinished。
+- 分句器最后一句及时刷新;公开 SSE 类型、文本顺序和终止事件保持兼容。
+- 抽取 JSON 不泄漏,原生节点和汇合在事件流中可见。
+- 断开取消剩余抽取,SQLite 未提交状态可恢复并重试。
+- 完整回复校验失败和业务提交失败均不产生虚假成功。
+- ChatOpenAI 的真实 HTTP mock 路径保留普通提取与流式回复的请求参数。
+
+官方接口参考:
+
+- https://docs.langchain.com/oss/python/langgraph/streaming
+- https://docs.langchain.com/oss/python/langgraph/use-graph-api
diff --git a/docs/assignment6-compat.md b/docs/assignment6-compat.md
index 706e43d..2951b9c 100644
--- a/docs/assignment6-compat.md
+++ b/docs/assignment6-compat.md
@@ -90,6 +90,18 @@
## 流式、并发与字段一致性约定
+- 事故采集的回复与抽取为独立 LangGraph 节点,通过原生条件分支并行,
+ `add_edge(["collect", "extract_accident_fields"], "merge_collect")` 汇合;
+ 分支各写独立通道,参考时间在 normalize_input 固定,合并后统一提交。
+ 人员抽取依赖本轮模型回复,暂保留串行,不改变其提示词和人员切换语义。
+- 模型文本与生命周期由原生 `astream_events` / `RunnableGenerator` 驱动,
+ 不再自行维护图后台任务、队列或 custom event bus。原始模型 token 先经过
+ 业务前缀校验;只有受控回复 Runnable 的文本流能映射为 TextDelta。
+ `validated_reply` 的正常 chain-end 映射为内部 ReplyFinished,立即刷新
+ 分句缓冲;它不会对外新增 SSE 事件,也不表示字段已提交。
+ 即使本轮无 formUpdate 或 needFormUpdate=false,最后一句也不等待抽取结束。
+ 业务提交失败仍发 error,不能发 formUpdate/done 冒充成功。
+
- 阶段前缀先缓冲并按当前节点的转换规则校验,非法前缀不推送;
未输出时仍可使用一次修复预算。已开始输出后,全文格式或时间依据
校验失败会以错误终止,不在同一流中拼接修复答案,不提交本轮业务字段。
diff --git a/docs/langgraph-backend-migration-plan.md b/docs/langgraph-backend-migration-plan.md
index a7810b4..7eaf37e 100644
--- a/docs/langgraph-backend-migration-plan.md
+++ b/docs/langgraph-backend-migration-plan.md
@@ -370,7 +370,7 @@ docs/langgraph/
| 5. 拍照与核实 | ✅ 完成 | 真实模型 4/4 + 12/12 + 2/2(20261021 基准) |
| 6. 四接口业务状态 | 已实现 | 应用服务、异步 repository、字段别名、会话串行与接口回归 |
| 7. 持久化 | SQLite/PostgreSQL 初版已实现 | `src/storage/`;真实两库集成、进程退出恢复、幂等和版本冲突;见 [agent-storage.md](agent-storage.md) |
-| 8. 真流式 | 已实现核心路径 | 模型增量、前缀校验、取消与提交回调;流式终止事件回归 |
+| 8. 真流式 | 已实现核心路径 | 原生 astream_events / Runnable 流、采集并行节点、回复校验后提前刷新;见 [agent-streaming.md](agent-streaming.md) |
| 9—10 | 未完成 | 生产访问边界、容量验证与双后端等价性验收继续保留 |
数据库实现阶段全量测试 315 通过,包含真实 SQLite/PostgreSQL 集成测试。
@@ -380,6 +380,11 @@ docs/langgraph/
提示词来源和逐字比对见 `test/agent/test_workflow_prompts.py`。下方真实模型
黄金评测结果是此前轮次,数据库开发和本次模型适配迁移均未重新运行 46 条评测。
+原生并行/流式更新:采集回复与字段提取通过图条件边并行并原生汇合,
+人员核实保留依赖回复的串行提取。validated_reply 的原生结束事件触发
+最后一句刷新,业务提交成功后才发送字段差量与 done。取消、失败和
+SQLite 快照恢复有独立回归;详见 [agent-streaming.md](agent-streaming.md)。
+
### 已落地的架构决策
1. **LLM 提议、代码校验(propose-validate)**:所有 stage_code 由节点内 LLM 产出(提示词即程序),代码只做前缀解析、白名单校验(含 clientMode 出口)和落库;曾尝试的代码强制推进/短促词守卫已按此分工移除。
@@ -420,6 +425,7 @@ src/agent/
confirm.py 核实节点(字段提取按 person_index 隔离)
llm.py LLMService Protocol / Fake / ChatOpenAI(超时重试温度) / workflow fake
chat_model.py ChatOpenAI 流式迭代器的显式关闭与并发上下文隔离
+ reply_stream.py 原生 RunnableGenerator 的前缀校验流
fields.py 字段三态校验;clock.py 可控时钟
prompts/ 七份版本化提示词(来源 nodeId + 逐字原文)
```
diff --git a/src/agent/collect.py b/src/agent/collect.py
index 572f282..74f69f1 100644
--- a/src/agent/collect.py
+++ b/src/agent/collect.py
@@ -92,6 +92,23 @@ async def validated_reply(
或关键字替模型选择动作。非法输出不写回图状态;修复仍失败则抛出
不含模型原文的错误。
"""
+ async def invoke(payload):
+ return await _validated_reply(**payload)
+
+ # 原生 chain-end 事件表示整条回复已经通过校验;它早于字段提取/业务提交。
+ from langchain_core.runnables import RunnableLambda
+
+ return await RunnableLambda(invoke).ainvoke(
+ dict(llm=llm, system_prompt=system_prompt, user_message=user_message,
+ current=current, validator=validator, history=history,
+ reply_validator=reply_validator),
+ config={"run_name": "validated_reply", "tags": ["reply"]},
+ )
+
+
+async def _validated_reply(
+ llm, system_prompt, user_message, current, *, validator, history, reply_validator,
+):
for attempt in range(2):
prompt = system_prompt
if attempt:
@@ -273,15 +290,15 @@ def _bookkeeping(state: AgentState) -> tuple[int, int]:
return no_reply_count, state.get("clarify_count") or 0
-async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
- """采集节点核心:一轮对话的 LLM 接入(参考实现)。
+def collect_time(state: AgentState, now: datetime | None = None) -> datetime:
+ if now is None and state.get("turn_observed_at"):
+ return datetime.fromisoformat(state["turn_observed_at"])
+ return observation_time(now)
- 流程:一次模型调用 → 前缀校验(不合规修复一次)→
- 白名单守卫(非法提议修复一次)→ 字段候选落库 → 返回状态增量。
- 任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
- 错误信息不携带原始模型内容。
- """
- moment = observation_time(now)
+
+async def collect_reply(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
+ """只生成并校验回复;不等待独立的字段抽取分支。"""
+ moment = collect_time(state, now)
evidence = time_evidence(state, moment)
system_prompt = render_prompt(COLLECTION, state, now=moment) + context_prompt(state, time_context=evidence)
user_message = _build_user_message(state, now=moment)
@@ -290,11 +307,6 @@ async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | Non
llm, system_prompt, user_message, current, history=conversation_history(state),
reply_validator=lambda code, body: valid_time_reply(code, body, evidence),
)
-
-
- # 4) 字段提取与表单差量(提取调用在对话调用之外,见 _extract_turn_fields)
- extracted = await _extract_turn_fields(state, llm, now=moment, time_context=evidence)
- accident_fields, patch = _merge_form(state, extracted)
no_reply_count, clarify_count = _bookkeeping(state)
# 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
@@ -311,8 +323,30 @@ async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | Non
"response_text": raw,
"stage_code": stage,
"raw_status_code": stage,
- "accident_fields": accident_fields,
- "turn_patch": patch,
"dialogue": dialogue,
"dialogue_timestamps": timestamps,
}
+
+
+async def collect_extraction(state: AgentState, llm: LLMService) -> dict:
+ """与回复读取同一份轮次快照,返回独立的候选结果通道。"""
+ moment = collect_time(state)
+ extracted = await _extract_turn_fields(
+ state, llm, now=moment, time_context=time_evidence(state, moment),
+ )
+ return {"extracted_accident_fields": extracted}
+
+
+def merge_collect(state: AgentState) -> dict:
+ form, patch = _merge_form(state, state.get("extracted_accident_fields") or {})
+ return {"accident_fields": form, "turn_patch": patch, "extracted_accident_fields": {}}
+
+
+async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
+ """独立调用入口;图内通过两个原生节点并行执行回复和提取。"""
+ moment = observation_time(now)
+ snapshot = {**state, "turn_observed_at": moment.isoformat()}
+ reply = await collect_reply(snapshot, llm)
+ extraction = await collect_extraction(snapshot, llm)
+ merged = merge_collect({**snapshot, **reply, **extraction})
+ return {**reply, **merged}
diff --git a/src/agent/graph.py b/src/agent/graph.py
index 060151d..7a49f56 100644
--- a/src/agent/graph.py
+++ b/src/agent/graph.py
@@ -8,6 +8,8 @@
- 节点间大切换由 ``route_by_stage`` 条件边按 ``stage_code`` 分发
(对照 Condition#2/#7/#9 等);一轮 HTTP 请求 = 一次图执行,
跨轮记忆由 checkpoint 承载;
+- 采集阶段回复与字段提取由原生条件分支同时调度,汇合后合并差量;
+ 两个分支读取同一轮时间与状态,各自写独立通道;
- 节点内阶段码一律由 LLM 提议(提示词即程序),代码只校验
(前缀格式、白名单、clientMode 出口)与落库;
- 依赖注入走 LangGraph 原生机制:``context_schema=GraphDeps``,
@@ -18,7 +20,8 @@
图结构::
START → normalize_input → route ─┬→ welcome(首轮固定问候)→ END
- ├→ collect ───────────→ END
+ ├→ collect ──────────┐
+ │ extract_accident_fields ─┴→ merge_collect → END
├→ single_photo ──────→ END
├→ dual_photo ────────→ END
├→ single_confirm ────→ END
@@ -40,7 +43,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.runtime import Runtime
-from .collect import run_collect
+from .collect import collect_reply, collect_extraction, collect_time, merge_collect
from .confirm import run_dual_confirm, run_single_confirm
from .events import parse_client_event
from .llm import LLMService, workflow_fake_llm
@@ -74,6 +77,10 @@ def normalize_input(state: AgentState) -> dict:
"input_text": cleaned,
"client_event": event.value if event else "",
"turn_patch": {},
+ "extracted_accident_fields": {},
+ "turn_observed_at": (
+ collect_time({}) if route_by_stage(state) == "collect" else observation_time()
+ ).isoformat(),
}
@@ -135,7 +142,16 @@ def welcome(state: AgentState) -> dict:
async def collect_node(state: AgentState, runtime: Runtime[GraphDeps]) -> dict:
"""事故信息收集节点(工作流「事故信息收集」chatNode)。"""
- return await run_collect(state, runtime.context.llm)
+ return await collect_reply(state, runtime.context.llm)
+
+
+async def extract_accident_fields(state: AgentState, runtime: Runtime[GraphDeps]) -> dict:
+ return await collect_extraction(state, runtime.context.llm)
+
+
+def route_turn(state: AgentState) -> str | list[str]:
+ target = route_by_stage(state)
+ return ["collect", "extract_accident_fields"] if target == "collect" else target
async def single_photo_node(state: AgentState, runtime: Runtime[GraphDeps]) -> dict:
@@ -163,7 +179,7 @@ def build_graph(checkpointer=None):
参数:
checkpointer: 自定义 checkpointer;缺省 ``InMemorySaver``,
- 作业 7 替换为 ``AsyncPostgresSaver``。
+ 持久化模式由运行时注入 SQLite/PostgreSQL saver。
LLM 依赖不在此处绑定——调用时通过
``config={"configurable": {"thread_id": <会话>}}`` 区分会话,
@@ -173,6 +189,8 @@ def build_graph(checkpointer=None):
builder.add_node("normalize_input", normalize_input)
builder.add_node("welcome", welcome)
builder.add_node("collect", collect_node)
+ builder.add_node("extract_accident_fields", extract_accident_fields)
+ builder.add_node("merge_collect", merge_collect)
builder.add_node("single_photo", single_photo_node)
builder.add_node("dual_photo", dual_photo_node)
builder.add_node("single_confirm", single_confirm_node)
@@ -181,10 +199,11 @@ def build_graph(checkpointer=None):
builder.add_edge(START, "normalize_input")
builder.add_conditional_edges(
"normalize_input",
- route_by_stage,
+ route_turn,
{
"welcome": "welcome",
"collect": "collect",
+ "extract_accident_fields": "extract_accident_fields",
"single_photo": "single_photo",
"dual_photo": "dual_photo",
"single_confirm": "single_confirm",
@@ -192,8 +211,10 @@ def build_graph(checkpointer=None):
END: END,
},
)
+ builder.add_edge(["collect", "extract_accident_fields"], "merge_collect")
+ builder.add_edge("merge_collect", END)
for name in (
- "welcome", "collect", "single_photo", "dual_photo",
+ "welcome", "single_photo", "dual_photo",
"single_confirm", "dual_confirm",
):
builder.add_edge(name, END)
diff --git a/src/agent/llm.py b/src/agent/llm.py
index 22daf80..7567635 100644
--- a/src/agent/llm.py
+++ b/src/agent/llm.py
@@ -201,6 +201,8 @@ class OpenAILLMService:
response = await client.ainvoke(
messages,
config={"run_name": "llm.complete", "metadata": {"llm_attempt": attempt + 1}},
+ # astream_events 的 callback 不能把抽取调用隐式改成流式。
+ stream=False,
)
content = self._text(response)
if not content:
diff --git a/src/agent/reply_stream.py b/src/agent/reply_stream.py
new file mode 100644
index 0000000..cac942d
--- /dev/null
+++ b/src/agent/reply_stream.py
@@ -0,0 +1,76 @@
+"""LangChain 原生 Runnable 文本流:在输出前验证阶段前缀。"""
+
+from contextlib import aclosing
+
+from langchain_core.runnables import RunnableGenerator
+
+from .collect import STATE_PREFIX_RE, parse_state_reply
+
+REPLY_TOKENS_RUN = "validated_reply_tokens"
+REPLY_RUN = "validated_reply"
+
+
+class ValidatedStreamingLLM:
+ """为现有 LLMService 添加受控流;抽取调用仍走 complete。
+
+ 流事件由 RunnableGenerator 原生生成,不自行创建任务或事件队列。
+ 每个请求独立实例,避免并行请求共享前缀/重试状态。
+ """
+
+ def __init__(self, inner):
+ self._inner = inner
+ self.response_started = False
+
+ async def complete(self, system_prompt, user_message, *, history=None):
+ return await self._inner.complete(system_prompt, user_message, history=history)
+
+ async def complete_validated(self, system_prompt, user_message, *, history=None, stage_validator):
+ raw = ""
+
+ async def generate(inputs):
+ nonlocal raw
+ # RunnableGenerator 接收上游输入流;每次调用恰好一个 payload。
+ async for _ in inputs:
+ stream_tokens = getattr(self._inner, "stream_tokens", None)
+ if stream_tokens is None:
+ raw = await self.complete(system_prompt, user_message, history=history)
+ try:
+ code, _ = parse_state_reply(raw)
+ except ValueError:
+ return
+ if stage_validator(code):
+ self.response_started = True
+ yield raw
+ return
+
+ chunks = []
+ prefix = ""
+ accepted = False
+ try:
+ async with aclosing(stream_tokens(system_prompt, user_message, history=history)) as source:
+ async for token in source:
+ chunks.append(token)
+ if not accepted:
+ prefix += token
+ match = STATE_PREFIX_RE.match(prefix)
+ if match is None:
+ if len(prefix) > 1024 or "" in prefix:
+ return
+ continue
+ if not stage_validator(match.group(1)):
+ return
+ accepted = True
+ self.response_started = True
+ yield prefix
+ else:
+ yield token
+ finally:
+ raw = "".join(chunks)
+
+ runnable = RunnableGenerator(generate).with_config(
+ run_name=REPLY_TOKENS_RUN, tags=["reply_tokens"],
+ )
+ async with aclosing(runnable.astream(None)) as source:
+ async for _ in source:
+ pass # 原生 astream_events 消费这些 chain-stream 事件。
+ return raw
diff --git a/src/agent/state.py b/src/agent/state.py
index a95c41c..02106d5 100644
--- a/src/agent/state.py
+++ b/src/agent/state.py
@@ -40,6 +40,8 @@ class AgentState(TypedDict):
clarify_count: int # 澄清计数(阈值按阶段配置,作业 4-9)
no_reply_count: int # 连续无回复计数(两次 → 0004)
turn_patch: dict # 本轮输出字段差量;每轮开始清空(作业 3-7)
+ turn_observed_at: str # 并行分支共用的服务器参考时间
+ extracted_accident_fields: dict # 抽取分支临时通道;汇合后清空
state_version: int # 业务版本(作业 7 并发冲突检测)
# ---- 对话上下文(作业 4/5) ----
diff --git a/src/api/endpoints.py b/src/api/endpoints.py
index c0cc50e..f624e69 100644
--- a/src/api/endpoints.py
+++ b/src/api/endpoints.py
@@ -11,6 +11,7 @@ from ..backends.chat import (
ChatBackendRateLimitError,
ChatInput,
FormUpdate,
+ ReplyFinished,
TextDelta,
)
from ..core.fastgpt_client import (
@@ -309,6 +310,17 @@ async def chat(
f"ttfb_ms={(time.perf_counter() - stream_started_at) * 1000:.1f}"
)
+ if isinstance(event, ReplyFinished):
+ # 回复已独立通过校验;抽取/提交可能仍在进行。
+ # 最后一句立即刷新,对外不新增事件,done 仍等待提交。
+ if state_code_found:
+ text = clean_later_state_tags("", final=True)
+ for text_event in build_text_delta_events(text):
+ yield text_event
+ for text_event in flush_text_chunker_events():
+ yield text_event
+ continue
+
if isinstance(event, FormUpdate) and not module_form_sent:
if event.data:
form_update_payload = event.data
diff --git a/src/backends/chat.py b/src/backends/chat.py
index a93a27c..715de2c 100644
--- a/src/backends/chat.py
+++ b/src/backends/chat.py
@@ -40,7 +40,12 @@ class FormUpdate:
data: Any
-ChatStreamEvent = TextDelta | FormUpdate
+@dataclass(frozen=True)
+class ReplyFinished:
+ """完整回复已经校验;用于刷新文本缓冲,不表示业务提交成功。"""
+
+
+ChatStreamEvent = TextDelta | FormUpdate | ReplyFinished
@dataclass(frozen=True)
diff --git a/src/backends/langgraph.py b/src/backends/langgraph.py
index 22c3868..5a29067 100644
--- a/src/backends/langgraph.py
+++ b/src/backends/langgraph.py
@@ -1,27 +1,19 @@
-"""LangGraph implementation of the backend-neutral chat contract(作业 2)。
+"""LangGraph 后端:原生 astream_events 驱动增量回复与结束信号。
-实现 ``ChatBackend`` Protocol,把图执行结果翻译为公共契约:
-- ``complete()`` 返回 ``ChatResult``,``content`` 保留
- ``XXXX`` 前缀(API 层负责解析);
-- ``stream()`` 产生 ``TextDelta`` / ``FormUpdate``。
- 作业 8 起为真实增量:图内对话调用的模型 token 逐条透出,
- 字段提取等辅助调用经标记旁路(见 _TokenPassThroughLLM)。
-
-通过 ``build_graph()`` 编译的图构造;会话用
-``configurable.thread_id = chat_input.session_id`` 绑定。
-
-对应测试:test/backends/test_langgraph_backend.py、
-test/backends/test_langgraph_streaming.py
+图调度、并行分支和事件生命周期交给 LangGraph/LangChain。
+只转发经过前缀校验的回复 Runnable 文本,抽取模型的原生事件不作为正文。
+业务提交成功后才公开 FormUpdate;公开 SSE done 仍由 API 层产生。
"""
-import asyncio
import inspect
-from contextlib import suppress
-from collections.abc import AsyncIterator, Awaitable, Callable
+from collections.abc import AsyncIterator
+from contextlib import aclosing
+
+from langchain_core.runnables import RunnableLambda
-from ..agent.collect import STATE_PREFIX_RE, parse_state_reply
from ..agent.graph import GraphDeps, build_graph, workflow_fake_llm
from ..agent.llm import LLMAuthError, LLMRateLimitError, LLMService
+from ..agent.reply_stream import REPLY_RUN, REPLY_TOKENS_RUN, ValidatedStreamingLLM
from .chat import (
ChatBackend,
ChatBackendAuthenticationError,
@@ -30,77 +22,15 @@ from .chat import (
ChatResult,
ChatStreamEvent,
FormUpdate,
+ ReplyFinished,
TextDelta,
)
-# 流式桥接的队列哨兵:图任务完成后入队,消费端据此收尾
-_DONE = object()
-
-
-class _TokenPassThroughLLM:
- """先验证阶段前缀;已经输出的回复不能重生成后拼接。"""
-
- def __init__(self, inner: LLMService, sink: Callable[[str], Awaitable[None]]):
- self._inner = inner
- self._sink = sink
- self.response_started = False
-
- async def complete(self, system_prompt, user_message, *, history=None):
- # 提取等未经过 validated_reply 的辅助调用始终静默。
- return await self._inner.complete(system_prompt, user_message, history=history)
-
- async def complete_validated(
- self, system_prompt, user_message, *, history=None, stage_validator,
- ) -> str:
- stream_tokens = getattr(self._inner, "stream_tokens", None)
- if stream_tokens is None:
- raw = await self.complete(system_prompt, user_message, history=history)
- try:
- code, _ = parse_state_reply(raw)
- except ValueError:
- return raw
- if stage_validator(code):
- self.response_started = True
- await self._sink(raw)
- return raw
-
- chunks: list[str] = []
- prefix = ""
- accepted = False
- source = stream_tokens(system_prompt, user_message, history=history)
- try:
- async for token in source:
- chunks.append(token)
- if not accepted:
- prefix += token
- match = STATE_PREFIX_RE.match(prefix)
- if match is None:
- if len(prefix) > 1024 or "" in prefix:
- return "".join(chunks)
- continue
- if not stage_validator(match.group(1)):
- return "".join(chunks)
- accepted = True
- self.response_started = True
- await self._sink(prefix)
- else:
- await self._sink(token)
- return "".join(chunks)
- finally:
- close = getattr(source, "aclose", None)
- if close is not None:
- await close()
+TURN_RUN = "accident_turn"
class LangGraphBackend(ChatBackend):
- """Adapt a compiled LangGraph to the neutral chat contract."""
-
def __init__(self, graph=None, llm=None):
- """参数:
- graph: 编译后的图;缺省调用 ``build_graph()``。
- llm: LLM 服务(运行时依赖);缺省为离线 workflow fake。
- 生产环境由 lifespan 注入 ``build_llm_service()`` 的实例。
- """
self._graph = graph if graph is not None else build_graph()
self._llm = llm if llm is not None else workflow_fake_llm()
@@ -109,17 +39,6 @@ class LangGraphBackend(ChatBackend):
await delete_checkpoint(self._graph, session_id)
async def complete(self, chat_input: ChatInput) -> ChatResult:
- """非流式:执行一轮图并翻译结果。
-
- TODO(作业 2): 实现该方法。要求:
- - 用 ``ainvoke`` 执行图,``thread_id`` 取
- ``chat_input.session_id``;
- - 初始 state 至少包含 ``session_id``、``input_text``;
- - 返回 ``ChatResult(content=<响应文本(含 state 前缀)>,
- status_code=None, form_update={})``;
- - 本阶段不处理 ``need_form_update`` 的真实表单差量
- (作业 6 起实现)。
- """
result = await self._complete_turn(chat_input)
patch = result.get("turn_patch") or {}
return ChatResult(
@@ -131,17 +50,7 @@ class LangGraphBackend(ChatBackend):
async def _complete_turn(
self, chat_input: ChatInput, llm: LLMService | None = None,
) -> dict:
- """执行一轮图,把 LLM 层错误翻译为后端中立错误。
-
- llm:运行时 LLM 依赖;缺省用构造注入的实例。stream 路径传入
- token 透传包装器(_TokenPassThroughLLM)。
-
- 错误码等价(对照 FastGPT 后端):
- LLMAuthError -> ChatBackendAuthenticationError(API 层 401);
- LLMRateLimitError -> ChatBackendRateLimitError(API 层 429)。
- """
- graph_input: dict = dict(chat_input.injected_fields or {})
- # 恢复快照中的上一轮 input_text 不得覆盖本次请求。
+ graph_input = dict(chat_input.injected_fields or {})
graph_input.update({
"session_id": chat_input.session_id,
"input_text": chat_input.text,
@@ -153,7 +62,6 @@ class LangGraphBackend(ChatBackend):
config={"configurable": {"thread_id": chat_input.session_id}},
context=GraphDeps(llm=llm or self._llm),
)
- # 即使消费者在图完成后断开,业务 repository 仍与 checkpoint 一致。
if chat_input.on_commit is not None:
pending = chat_input.on_commit(result.get("turn_patch") or {}, state=result)
if inspect.isawaitable(pending):
@@ -165,58 +73,43 @@ class LangGraphBackend(ChatBackend):
raise ChatBackendRateLimitError(str(exc)) from exc
async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]:
- """流式(作业 8 真实增量):模型 token 逐条透出。
+ llm = ValidatedStreamingLLM(self._llm)
- 结构:后台任务执行一轮图(LLM 经 _TokenPassThroughLLM 把
- 对话 token 推入队列),本生成器从队列消费并产出
- ``TextDelta``;图完成后产出 ``FormUpdate``(差量)。
- - API 层的缓冲解析(state 标签跨片段拼接、stage 事件优先)
- 本就支持任意分片,无需改动;
- - 生成器被关闭(客户端断开)时取消图任务,停止 LLM 调用;
- - 图任务异常在消费端重新抛出(错误映射沿用 _complete_turn)。
- """
- queue: asyncio.Queue = asyncio.Queue(maxsize=32)
- token_count = 0
+ async def run_turn(request):
+ return await self._complete_turn(request, llm=llm)
- async def sink(token: str) -> None:
- nonlocal token_count
- token_count += 1
- await queue.put(token)
+ turn = RunnableLambda(run_turn).with_config(run_name=TURN_RUN)
+ text_started = False
+ reply_finished = False
+ result = None
+ # 底层负责关闭时取消并等待任务,避免业务锁释放后残留图执行。
+ source = turn.astream_events(
+ chat_input, version="v2",
+ include_names=[TURN_RUN, REPLY_RUN, REPLY_TOKENS_RUN],
+ )
+ async with aclosing(source):
+ async for event in source:
+ name = event["name"]
+ kind = event["event"]
+ if name == REPLY_TOKENS_RUN and kind == "on_chain_stream":
+ text = event["data"]["chunk"]
+ if text:
+ text_started = True
+ yield TextDelta(text)
+ elif name == REPLY_RUN and kind == "on_chain_end":
+ # validated_reply 正常结束,完整回复才算通过校验。
+ if not reply_finished:
+ reply_finished = True
+ yield ReplyFinished()
+ elif name == TURN_RUN and kind == "on_chain_end" and not event["parent_ids"]:
+ result = event["data"]["output"]
- async def run_turn() -> dict:
- try:
- result = await self._complete_turn(
- chat_input, llm=_TokenPassThroughLLM(self._llm, sink)
- )
- except asyncio.CancelledError:
- raise
- except Exception as exc:
- await queue.put(exc)
- return {}
- await queue.put(_DONE)
- return result
-
- task = asyncio.ensure_future(run_turn())
- try:
- while True:
- item = await queue.get()
- if item is _DONE:
- break
- if isinstance(item, BaseException):
- raise item
- yield TextDelta(item)
- result = await task
- if token_count == 0:
- # 本回合无 LLM 调用(welcome 等固定应答轮):
- # sink 无 token 可推,回退为完整文本切片(旧行为)
- text = result.get("response_text") or ""
- if text:
- yield TextDelta(text)
- form_update = result.get("turn_patch") or {}
- if form_update and chat_input.need_form_update:
- yield FormUpdate(form_update)
- finally:
- if not task.done():
- task.cancel()
- with suppress(asyncio.CancelledError):
- await task
+ if result is None:
+ raise RuntimeError("Graph stream ended without a committed result")
+ if not text_started:
+ text = result.get("response_text") or ""
+ if text:
+ yield TextDelta(text)
+ patch = result.get("turn_patch") or {}
+ if patch and chat_input.need_form_update:
+ yield FormUpdate(patch)
diff --git a/test/backends/test_native_parallel_streaming.py b/test/backends/test_native_parallel_streaming.py
new file mode 100644
index 0000000..a871521
--- /dev/null
+++ b/test/backends/test_native_parallel_streaming.py
@@ -0,0 +1,298 @@
+"""并行节点和原生流事件的时序、提交与取消契约。"""
+
+import asyncio
+from contextlib import aclosing
+import json
+
+import httpx
+import pytest
+
+from src.agent.collect import CollectReplyError
+from src.agent.graph import build_graph
+from src.agent.llm import OpenAILLMService
+from src.api import endpoints
+from src.backends.chat import ChatInput, FormUpdate, ReplyFinished, TextDelta
+from src.backends.langgraph import LangGraphBackend
+from src.core.config import Config
+from src.schemas.models import ProcessRequest_chat
+from src.services.app_service import AccidentAppService
+from src.storage.runtime import StorageSettings, open_storage
+from src.services.repository import InMemorySessionRepository
+
+
+class ParallelLLM:
+ def __init__(self, patch=None):
+ self.patch = patch if patch is not None else {'has_injury': False}
+ self.extraction_started = asyncio.Event()
+ self.reply_started = asyncio.Event()
+ self.release_extraction = asyncio.Event()
+ self.extraction_cancelled = asyncio.Event()
+ self.reply_done = asyncio.Event()
+
+ async def complete(self, system, user, **kwargs):
+ if '【字段提取】' in system:
+ self.extraction_started.set()
+ try:
+ await self.release_extraction.wait()
+ except asyncio.CancelledError:
+ self.extraction_cancelled.set()
+ raise
+ return json.dumps(self.patch)
+ self.reply_started.set()
+ # 若是串行实现,会卡在这里;两个原生图节点必须同时运行。
+ await self.extraction_started.wait()
+ self.reply_done.set()
+ return '1002请问您还在事故现场吗?'
+
+ async def stream_tokens(self, system, user, **kwargs):
+ self.reply_started.set()
+ await self.extraction_started.wait()
+ yield '1002'
+ yield '请问您还在事故现场吗?'
+ self.reply_done.set()
+
+
+async def service_for(llm):
+ repository = InMemorySessionRepository()
+ backend = LangGraphBackend(llm=llm)
+ service = AccidentAppService(backend, repository)
+ await service.chat(ChatInput('session', 'start'))
+ return service, repository
+
+
+@pytest.mark.asyncio
+async def test_non_stream_nodes_fan_out_before_join_and_commit():
+ llm = ParallelLLM()
+ service, repo = await service_for(llm)
+ task = asyncio.create_task(service.chat(ChatInput('session', '继续')))
+ try:
+ await asyncio.wait_for(llm.reply_done.wait(), 3)
+ assert llm.extraction_started.is_set()
+ assert not task.done()
+ assert await repo.load('session') == {}
+ llm.release_extraction.set()
+ await asyncio.wait_for(task, 3)
+ assert (await repo.load('session'))['ywrysw'] is False
+ finally:
+ llm.release_extraction.set()
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_reply_finished_precedes_slow_extraction_and_commit():
+ llm = ParallelLLM()
+ service, repo = await service_for(llm)
+ events = []
+ async with aclosing(service.stream(ChatInput('session', '继续', need_form_update=True))) as source:
+ while True:
+ event = await asyncio.wait_for(anext(source), 3)
+ events.append(event)
+ if isinstance(event, ReplyFinished):
+ break
+ assert llm.reply_done.is_set()
+ assert not llm.release_extraction.is_set()
+ assert all(not isinstance(event, FormUpdate) for event in events)
+ assert await repo.load('session') == {}
+ llm.release_extraction.set()
+ events.extend([event async for event in source])
+ assert isinstance(events[-1], FormUpdate)
+ assert (await repo.load('session'))['ywrysw'] is False
+ assert not service._locks._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('patch', [{}, {'has_injury': False}])
+@pytest.mark.parametrize('visible', [False, True])
+async def test_sse_flushes_final_sentence_before_extraction_with_unchanged_public_events(monkeypatch, patch, visible):
+ llm = ParallelLLM(patch)
+ service, repo = await service_for(llm)
+ monkeypatch.setattr(Config, 'AGENT_BACKEND', 'langgraph')
+ monkeypatch.setattr(endpoints, 'get_app_service', lambda: service)
+ response = await endpoints.chat(
+ ProcessRequest_chat(sessionId='session', timeStamp='probe', text='继续',
+ needFormUpdate=visible, useTextChunk=True),
+ stream=True, backend=service._backend, idempotency_key=None,
+ )
+ received = []
+ async with aclosing(response.body_iterator) as source:
+ while True:
+ event = await asyncio.wait_for(anext(source), 3)
+ received.append(event)
+ if 'event: text_delta' in event:
+ break
+ assert '请问您还在事故现场吗?' in received[-1]
+ assert llm.extraction_started.is_set()
+ assert not llm.release_extraction.is_set()
+ assert not any('event: done' in event for event in received)
+ assert await repo.load('session') == {}
+ llm.release_extraction.set()
+ received.extend([event async for event in source])
+ body = ''.join(received)
+ assert body.count('event: text_delta') == 1
+ assert body.count('event: done') == 1
+ assert ('event: formUpdate' in body) is (visible and bool(patch))
+ assert 'reply_finished' not in body
+ if not visible:
+ assert 'has_injury' not in body
+
+
+@pytest.mark.asyncio
+async def test_closing_after_reply_finished_cancels_extraction_before_session_unlock():
+ llm = ParallelLLM()
+ service, repo = await service_for(llm)
+ async with aclosing(service.stream(ChatInput('session', '继续'))) as source:
+ while not isinstance(await asyncio.wait_for(anext(source), 3), ReplyFinished):
+ pass
+ assert llm.extraction_cancelled.is_set()
+ assert not service._locks._entries
+ assert await repo.load('session') == {}
+
+
+@pytest.mark.asyncio
+async def test_invalid_full_reply_never_emits_finished_or_commits_and_cancels_parallel_extraction():
+ class Invalid(ParallelLLM):
+ async def stream_tokens(self, *args, **kwargs):
+ await self.extraction_started.wait()
+ yield '1002partial'
+ yield '9999'
+ llm = Invalid()
+ service, repo = await service_for(llm)
+ events = []
+ with pytest.raises(CollectReplyError):
+ async for event in service.stream(ChatInput('session', '继续')):
+ events.append(event)
+ assert any(isinstance(event, TextDelta) for event in events)
+ assert not any(isinstance(event, ReplyFinished) for event in events)
+ assert llm.extraction_cancelled.is_set()
+ assert await repo.load('session') == {}
+ assert not service._locks._entries
+
+
+@pytest.mark.asyncio
+async def test_native_graph_nodes_are_visible_and_join_once():
+ llm = ParallelLLM()
+ llm.release_extraction.set()
+ graph = build_graph()
+ from src.agent.graph import GraphDeps
+ names = []
+ async for event in graph.astream_events(
+ {'session_id': 'native', 'input_text': '继续', 'stage_code': '1002'},
+ config={'configurable': {'thread_id': 'native'}}, context=GraphDeps(llm=llm), version='v2',
+ ):
+ if event['event'] == 'on_chain_end':
+ names.append(event['name'])
+ assert names.count('collect') == names.count('extract_accident_fields') == names.count('merge_collect') == 1
+
+
+@pytest.mark.asyncio
+async def test_confirmation_reply_finishes_before_its_dependent_extraction():
+ class LLM:
+ def __init__(self):
+ self.release = asyncio.Event()
+ async def complete(self, *args, **kwargs):
+ await self.release.wait()
+ return '{}'
+ async def stream_tokens(self, *args, **kwargs):
+ yield '3002请问您的姓名是什么?'
+ llm = LLM()
+ backend = LangGraphBackend(llm=llm)
+ async with aclosing(backend.stream(ChatInput(
+ 'confirm', '是的', injected_fields={'stage_code': '3002'},
+ ))) as source:
+ received = []
+ while True:
+ event = await asyncio.wait_for(anext(source), 3)
+ received.append(event)
+ if isinstance(event, ReplyFinished):
+ break
+ assert ''.join(event.text for event in received if isinstance(event, TextDelta)) == '3002请问您的姓名是什么?'
+ assert not llm.release.is_set()
+ llm.release.set()
+ assert [event async for event in source] == []
+
+
+@pytest.mark.asyncio
+async def test_sqlite_disconnect_after_reply_finishes_preserves_committed_snapshot(tmp_path):
+ llm = ParallelLLM()
+ settings = StorageSettings(kind='sqlite', sqlite_path=str(tmp_path/'parallel.sqlite3'), auto_migrate=True)
+ async with open_storage(settings) as storage:
+ backend = LangGraphBackend(build_graph(storage.checkpointer), llm=llm)
+ service = AccidentAppService(backend, storage.repository, locks=storage.locks)
+ await service.chat(ChatInput('session', 'start'))
+ async with aclosing(service.stream(ChatInput('session', '继续', request_key='interrupted'))) as source:
+ while not isinstance(await asyncio.wait_for(anext(source), 3), ReplyFinished):
+ pass
+ assert llm.extraction_cancelled.is_set()
+ assert not storage.repository.database.connection.in_transaction
+ assert not storage.checkpointer.conn.in_transaction
+ async with storage.repository.database.transaction() as conn:
+ row = await conn.one('SELECT version,graph_state FROM agent_sessions WHERE session_id=?', ('session',))
+ assert row['version'] == 1
+ assert json.loads(row['graph_state'])['turn_count'] == 1
+ llm.release_extraction.set()
+ await service.chat(ChatInput('session', '继续', request_key='interrupted'))
+ assert (await storage.repository.load('session'))['ywrysw'] is False
+ assert not service._locks._entries
+
+
+@pytest.mark.asyncio
+async def test_commit_failure_after_early_reply_still_emits_error_without_form_update_or_done(monkeypatch):
+ llm = ParallelLLM()
+ service, repo = await service_for(llm)
+ monkeypatch.setattr(Config, 'AGENT_BACKEND', 'langgraph')
+ monkeypatch.setattr(endpoints, 'get_app_service', lambda: service)
+ async def fail(*args):
+ raise OSError('commit unavailable')
+ monkeypatch.setattr(repo, 'apply_update', fail)
+ response = await endpoints.chat(
+ ProcessRequest_chat(sessionId='session', timeStamp='probe', text='继续',
+ needFormUpdate=True, useTextChunk=True),
+ stream=True, backend=service._backend, idempotency_key=None,
+ )
+ received = []
+ async with aclosing(response.body_iterator) as source:
+ while True:
+ event = await asyncio.wait_for(anext(source), 3)
+ received.append(event)
+ if 'event: text_delta' in event:
+ break
+ llm.release_extraction.set()
+ received.extend([event async for event in source])
+ body = ''.join(received)
+ assert 'event: text_delta' in body and 'event: error' in body
+ assert 'event: done' not in body and 'event: formUpdate' not in body
+ assert await repo.load('session') == {}
+
+
+@pytest.mark.asyncio
+async def test_real_chatopenai_native_events_do_not_leak_extraction_or_force_streaming(monkeypatch):
+ requests = []
+ def handler(request):
+ payload = json.loads(request.content)
+ requests.append(payload)
+ extraction = '【字段提取】' in payload['messages'][0]['content']
+ if extraction:
+ assert payload['stream'] is False
+ return httpx.Response(200, json={
+ 'id': 'extract', 'object': 'chat.completion', 'created': 0, 'model': 'probe',
+ 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': '{"has_injury":false}'}, 'finish_reason': 'stop'}],
+ })
+ assert payload['stream'] is True
+ data = {'id': 'reply', 'object': 'chat.completion.chunk', 'created': 0, 'model': 'probe',
+ 'choices': [{'index': 0, 'delta': {'content': '1002请问您还在事故现场吗?'}, 'finish_reason': None}]}
+ return httpx.Response(200, headers={'content-type': 'text/event-stream'},
+ content='data: '+json.dumps(data)+'\n\ndata: [DONE]\n\n')
+ monkeypatch.setattr(Config, 'LLM_HTTP_TRUST_ENV', False)
+ llm = OpenAILLMService('probe', 'offline', base_url='http://probe.local/v1')
+ llm._get_client().http_async_client._transport = httpx.MockTransport(handler)
+ try:
+ service, repo = await service_for(llm)
+ events = [event async for event in service.stream(ChatInput('session', '继续', need_form_update=True))]
+ text = ''.join(event.text for event in events if isinstance(event, TextDelta))
+ assert text == '1002请问您还在事故现场吗?'
+ assert len(requests) == 2
+ assert sum(isinstance(event, ReplyFinished) for event in events) == 1
+ assert isinstance(events[-1], FormUpdate)
+ finally:
+ await llm.aclose()
diff --git a/test/services/test_stream_retry_cleanup.py b/test/services/test_stream_retry_cleanup.py
index 2de87a6..991ec4a 100644
--- a/test/services/test_stream_retry_cleanup.py
+++ b/test/services/test_stream_retry_cleanup.py
@@ -37,7 +37,7 @@ async def test_valid_prefix_reaches_client_before_model_finishes():
assert not finished.is_set()
release.set()
remaining = [e async for e in events]
- assert ''.join(e.text for e in remaining) == 'tail'
+ assert ''.join(e.text for e in remaining if isinstance(e, TextDelta)) == 'tail'
assert finished.is_set()