perf(chain): review 节点并行扇出五审——复用主图 add-only 记账并发范式(CR-M3-6)

This commit is contained in:
Yaojia Wang
2026-07-08 13:08:19 +02:00
parent ec91c8fdf7
commit 6a739cad5e
2 changed files with 144 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ asyncio_mode=auto。
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -522,3 +523,137 @@ async def test_continue_volume_chain_second_chapter_sees_first_chapter_text() ->
# 第 1 章请求无前文(前一章 0 不存在)→ 降级占位,不报错。 # 第 1 章请求无前文(前一章 0 不存在)→ 降级占位,不报错。
first_write_input = h["gateway"].write_inputs[0] first_write_input = h["gateway"].write_inputs[0]
assert "前文正文" in first_write_input assert "前文正文" in first_write_input
# ---- CR-M3-6 review 节点并行扇出 ----
class _ConcurrencyReviewGateway:
"""记录审稿并发峰值的 mock 网关:各审 run 之间 `await` 让出——并行扇出时 active 峰值 > 1。
串行 for 循环(旧实现)每审跑完才起下一审 → active 恒为 1并行 gather → 峰值 = 审稿数。
审稿按 `output_schema` 回各自空实例 parsed`by_schema` 覆盖全部审 spec 的 schema
"""
def __init__(self, *, by_schema: dict[type, BaseModel]) -> None:
self._by_schema = by_schema
self._active = 0
self.peak = 0
self.review_calls = 0
async def run(self, req: LlmRequest) -> LlmResponse:
schema = req.output_schema
assert schema is not None # review 节点仅发结构化审稿请求
self.review_calls += 1
self._active += 1
self.peak = max(self.peak, self._active)
await asyncio.sleep(0) # 让出:并行扇出时其余审得以在此同时进入(峰值 > 1
self._active -= 1
parsed = self._by_schema[schema]
return LlmResponse(
text=parsed.model_dump_json(),
parsed=parsed,
usage=_usage(),
served_by=ServedBy(provider="fake", model="fake"),
)
class _CapturingReviewRepo:
"""捕获 collect 落库的各列 kwargs断言五审全部落齐一条留痕"""
def __init__(self) -> None:
self.recorded: dict[str, Any] | None = None
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
characterization: dict[str, Any] | None = None,
health_score: int | None = None,
) -> Any:
self.recorded = {
"conflicts": conflicts,
"foreshadow_sug": foreshadow_sug,
"style": style,
"pace": pace,
"characterization": characterization,
}
return object()
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]:
return []
async def test_review_chapter_fans_out_reviews_in_parallel_and_records_all() -> None:
"""review 节点并行扇出五审(复用主图 add-only 记账并发范式T3.8)→ 一次 collect 落齐各列。
并发证据mock 网关审稿并发峰值 > 1串行恒 1。正确性五审各列全部落一条留痕。
"""
from ww_agents import (
characterization_spec,
continuity_spec,
foreshadow_spec,
pace_spec,
style_drift_spec,
)
from ww_core.orchestrator.chain import review_chapter
specs = (
continuity_spec,
foreshadow_spec,
pace_spec,
style_drift_spec,
characterization_spec,
)
by_schema: dict[type, BaseModel] = {}
for spec in specs:
assert spec.output_schema is not None
by_schema[spec.output_schema] = spec.output_schema()
gateway = _ConcurrencyReviewGateway(by_schema=by_schema)
review_repo = _CapturingReviewRepo()
draft_store = {5: "第 5 章草稿正文。"}
async def gateway_builder(session: Any) -> Any:
return gateway
def memory_repos_factory(session: Any) -> Any:
return object()
def chapter_repo_factory(session: Any) -> Any:
return FakeChapterRepo(draft_store)
def review_repo_factory(session: Any) -> Any:
return review_repo
async def assemble(repos: Any, project_id: uuid.UUID, chapter_no: int) -> Any:
return FakeContext()
state = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=5, count=1)
await review_chapter(
state,
gateway_builder=gateway_builder,
session_factory=_session_factory,
memory_repos_factory=memory_repos_factory,
chapter_repo_factory=chapter_repo_factory,
review_repo_factory=review_repo_factory,
assemble=assemble,
review_specs=specs,
)
# 并发:五审全部起跑且并发峰值 > 1串行实现恒为 1 → 本断言 RED
assert gateway.review_calls == 5
assert gateway.peak >= 2
# 正确性五审各列均落一条留痕continuity→conflicts、foreshadow→sug、pace/style/角色塑造)。
assert review_repo.recorded is not None
assert review_repo.recorded["conflicts"] == []
assert review_repo.recorded["foreshadow_sug"] == []
assert review_repo.recorded["pace"] is not None
assert review_repo.recorded["style"] is not None
assert review_repo.recorded["characterization"] is not None

View File

@@ -10,6 +10,7 @@ checkpoint 只存控制流,正文/审稿落领域表——resume 时重读领
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from collections.abc import Awaitable, Callable, Sequence from collections.abc import Awaitable, Callable, Sequence
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
@@ -210,9 +211,15 @@ async def review_chapter(
"user_id": user_id, "user_id": user_id,
"review_context": review_context, "review_context": review_context,
} }
# 并行扇出五审(复用主图 write→review 的 add-only 记账并发范式T3.8):各 run_review
# 共用本节点 session 的网关,其 SqlAlchemyLedgerSink.record 只 add同步、不 flush
# 共享 session 并发安全run_review 自带失败隔离(不抛、返回 incomplete 占位gather
# 无需 return_exceptions。collect_reviews 在 gather 之后**一次**落库(不引入新并发写)。
parts = await asyncio.gather(
*(run_review(spec, review_state, gateway=gateway) for spec in review_specs)
)
reviews: dict[str, Any] = {} reviews: dict[str, Any] = {}
for spec in review_specs: for part in parts:
part = await run_review(spec, review_state, gateway=gateway)
reviews.update(part.get("reviews", {})) reviews.update(part.get("reviews", {}))
review_state["reviews"] = reviews review_state["reviews"] = reviews
review_repo = review_repo_factory(session) review_repo = review_repo_factory(session)