Files

379 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""实时流跨进程(docs/design/orchestration-round/00-总方案.md §5 W2-b「实时流跨进程」)。
**为什么。** 运行的事件先落库、再在本进程的事件总线上推给订阅方(core/bus);问数据一轮的事件同样先落库,再叫醒本进程里
等着的流(chat/log)。多进程时,页面的 WebSocket / SSE 连到的进程不一定是执行这次运行、这一轮的那个进程。
**怎么做。** 订阅方在哪个进程,就由那个进程的转发器(Relay)从库里按 seq 追:
- 运行:WebSocket 接上之后登记跟随(follow_run)。执行它的进程每提交一条事件,发 NOTIFY r|run_id|seq(cluster/signal,
攒一下批量发);跟随它的进程收到后,只要这次运行不在本进程里执行,就从库里读 seq 大于手上游标的事件、在本进程的总线上
发布一遍(relayed,不再往外转),订阅方收到的和执行它的进程上的一模一样,序号由执行的进程发、不重复。到了终态(或者
挂起、属主不在了被标中断)再关掉本进程的订阅方,同执行进程上的规矩。NOTIFY 丢了也不怕:跟随的运行隔 POLL_S 秒回库里看一次。
- 流式增量(正文 token、思考 delta)不落库:跟随的进程隔几秒发一次「我在看」(w|r:run_id),执行的进程只给有人在看的运行把
增量攒一小段、放进载荷转过去(e|run_id|[事件…])。
- 问数据一轮:流本来就是从库里读的,这里只管叫醒——追加一条事件、任务状态变了都发 t|turn_id,收到的进程叫醒本进程里等着
这一轮的流;助手的保活心跳(不落库,chat/log.beat)同样只给有人在看的那几轮转过去(b|turn_id|心跳)。
- SQLite 只有一个进程:signal 不收不发,这里什么都不做(单进程照旧走进程内的叫醒,不额外轮询)。
WebSocket / SSE 的回放、重连、心跳的说法都不变:回放照旧读库,转发器只补「实时」那一段。
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
from collections.abc import Iterator
from typing import Any
from sqlalchemy import select
from app.cluster.signal import signals
from app.core.events import EventType, RunEventModel
from app.db.base import SessionLocal
logger = logging.getLogger(__name__)
#: 在看的一方隔多久重发一次「我在看」;执行的一方收到后记多久
WATCH_S = 5.0
WATCH_TTL_S = 15.0
#: 流式增量攒多久转一次
EPHEMERAL_FLUSH_S = 0.05
#: 跟随的运行隔多久回库里看一次(NOTIFY 丢了的兜底)
POLL_S = 2.0
#: 一条载荷最多多少字节(PostgreSQL 的 NOTIFY 上限是 8000)
PAYLOAD_MAX = 7000
#: 往回多读几条(复核 R-b5):号是先取后提交的,号大的先提交时游标会越过号小的那一条;每次从游标往回留这么多条重读,按已经
#: 发布过的 seq 去重
LOOKBACK = 64
#: 不落库的事件(同 engine/runner._EPHEMERAL)
EPHEMERAL = {EventType.LLM_TOKEN, EventType.LLM_THINKING_DELTA}
_TERMINAL = ("succeeded", "failed", "cancelled")
class Relay:
def __init__(self) -> None:
#: 本进程里在跟随的运行:{run_id: 跟随的连接数}、游标(已经在本进程发布过的最大 seq)
self._follow: dict[str, int] = {}
self._cursor: dict[str, int] = {}
#: 本进程已经发布过的 seq(游标往回 LOOKBACK 条以内的):往回重读时去重
self._seen: dict[str, set[int]] = {}
#: 接上时回放到了哪一条:往回重读不越过它(那之前的回放已经发过)
self._floor: dict[str, int] = {}
self._pumping: dict[str, asyncio.Lock] = {}
#: 本进程里在看的问数据一轮
self._turns: dict[str, int] = {}
#: 别的进程在看的(执行的一方记):{run_id / turn_id: 到期的时刻}
self._watched_runs: dict[str, float] = {}
self._watched_turns: dict[str, float] = {}
#: 攒着要转的流式增量:{run_id: [事件]}
self._buffer: dict[str, list[dict[str, Any]]] = {}
self._flusher: asyncio.Task[None] | None = None
self._loop_task: asyncio.Task[None] | None = None
self._installed = False
# ---------------- 起停 ----------------
def install(self) -> None:
"""登记收通知的处理、接上事件总线(起服务时,PostgreSQL 才有意义;重复调用无害)。"""
from app.core.bus import bus
if not self._installed:
signals.on("r", self._on_run)
signals.on("e", self._on_ephemeral, raw=True)
signals.on("w", self._on_watch)
signals.on("t", self._on_turn)
signals.on("b", self._on_beat, raw=True)
signals.on_resync(self._resync)
self._installed = True
bus.tap = self._tap
async def start(self) -> None:
self.install()
if signals.active and (self._loop_task is None or self._loop_task.done()):
self._loop_task = asyncio.create_task(self._loop(), name="agentlab-cluster-relay")
async def stop(self) -> None:
from app.core.bus import bus
if bus.tap == self._tap:
bus.tap = None
for task in (self._loop_task, self._flusher):
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
self._loop_task = self._flusher = None
self._buffer.clear()
# ---------------- 跟随(订阅方所在的进程) ----------------
@contextlib.contextmanager
def follow_run(self, run_id: str, after: int = 0) -> Iterator[None]:
"""这条连接在看这次运行(WebSocket 接上、回放完之后):在本进程里没在执行时从库里追。"""
self._follow[run_id] = self._follow.get(run_id, 0) + 1
self._cursor[run_id] = max(self._cursor.get(run_id, 0), int(after or 0))
# 回放已经发到 after 为止(订阅位那边按 after 去重):往回重读不越过它
self._floor[run_id] = max(self._floor.get(run_id, 0), int(after or 0)) if run_id not in self._floor else \
min(self._floor[run_id], int(after or 0))
if signals.active:
signals.publish("w", f"r:{run_id}")
self._kick(run_id)
try:
yield
finally:
left = self._follow.get(run_id, 1) - 1
if left <= 0:
self._follow.pop(run_id, None)
self._cursor.pop(run_id, None)
self._seen.pop(run_id, None)
self._floor.pop(run_id, None)
self._pumping.pop(run_id, None)
else:
self._follow[run_id] = left
@contextlib.contextmanager
def watch_turn(self, turn_id: str) -> Iterator[None]:
"""这条 SSE 在看这一轮:执行它的进程把保活心跳转过来。"""
self._turns[turn_id] = self._turns.get(turn_id, 0) + 1
if signals.active:
signals.publish("w", f"t:{turn_id}")
try:
yield
finally:
left = self._turns.get(turn_id, 1) - 1
if left <= 0:
self._turns.pop(turn_id, None)
else:
self._turns[turn_id] = left
def _kick(self, run_id: str) -> None:
if run_id in self._follow:
task = asyncio.ensure_future(self.pump(run_id))
task.add_done_callback(_quiet)
async def pump(self, run_id: str) -> int:
"""把库里 seq 大于游标的事件在本进程发布一遍(这次运行在本进程里执行时什么都不做)。交回发布了几条。"""
from app.core.bus import bus
from app.db.models import Run, RunEvent
from app.engine.runner import run_manager
if run_id not in self._follow or run_manager.is_active(run_id):
return 0
lock = self._pumping.setdefault(run_id, asyncio.Lock())
async with lock:
after = self._cursor.get(run_id, 0)
floor = self._floor.get(run_id, 0)
seen = self._seen.setdefault(run_id, set())
async with SessionLocal() as session:
# 先读状态、再读事件:终态和终态事件是同一个事务提交的,读到终态时终态事件一定也读得到
status = await session.scalar(select(Run.status).where(Run.id == run_id))
fetched = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.seq > max(floor, after - LOOKBACK))
.order_by(RunEvent.seq))).scalars())
rows = [r for r in fetched if int(r.seq or 0) not in seen and int(r.seq or 0) > floor]
last_log = None
for row in rows:
self._cursor[run_id] = max(self._cursor.get(run_id, 0), int(row.seq or 0))
seen.add(int(row.seq or 0))
last_log = row
try:
event = RunEventModel(id=row.id, run_id=run_id, seq=row.seq, type=EventType(row.type),
node_id=row.node_id, ts=row.ts, data=row.data or {})
except ValueError:
continue # 不认识的事件种类(更新的版本写的):回放时照样读得到,这里不转
await bus.publish(event, relayed=True)
low = self._cursor.get(run_id, 0) - 2 * LOOKBACK
if len(seen) > 4 * LOOKBACK:
self._seen[run_id] = {q for q in seen if q > low}
ended = status is None or status in _TERMINAL or (
status == "interrupted" and last_log is not None and last_log.type == EventType.LOG
and (last_log.data or {}).get("code") == "server_shutdown")
if ended and (rows or status is None or status in _TERMINAL):
# 同执行进程上的规矩(runner._finalize):到了终态、关停挂起的,让本进程的订阅方收到终态再结束
await bus.close(run_id)
return len(rows)
def _on_run(self, parts: list[str]) -> None:
if parts:
self._kick(parts[0])
def _on_ephemeral(self, parts: list[str]) -> None:
from app.core.bus import bus
from app.engine.runner import run_manager
if len(parts) < 2 or parts[0] not in self._follow or run_manager.is_active(parts[0]):
return
try:
items = json.loads(parts[1])
except ValueError:
return
for item in items if isinstance(items, list) else []:
try:
event = RunEventModel(**item)
except Exception: # noqa: BLE001
continue
asyncio.ensure_future(bus.publish(event, relayed=True)).add_done_callback(_quiet)
def _on_turn(self, parts: list[str]) -> None:
if parts:
from app.chat import log as turn_log
turn_log.waiters.notify(parts[0])
def _on_beat(self, parts: list[str]) -> None:
if len(parts) < 2 or parts[0] not in self._turns:
return
try:
op = json.loads(parts[1])
except ValueError:
return
if isinstance(op, dict):
from app.chat import log as turn_log
turn_log.remote_beat(parts[0], op)
async def _resync(self) -> None:
for run_id in list(self._follow):
await self.pump(run_id)
from app.chat import log as turn_log
for turn_id in list(self._turns):
turn_log.waiters.notify(turn_id)
async def _loop(self) -> None:
"""跟随方:隔 WATCH_S 秒重发「我在看」,隔 POLL_S 秒回库里看一次跟随的运行。"""
last_watch = 0.0
while True:
await asyncio.sleep(POLL_S)
now = time.monotonic()
if now - last_watch >= WATCH_S:
last_watch = now
for run_id in list(self._follow):
signals.publish("w", f"r:{run_id}")
for turn_id in list(self._turns):
signals.publish("w", f"t:{turn_id}")
for run_id in list(self._follow):
try:
await self.pump(run_id)
except Exception: # noqa: BLE001
logger.debug("转发运行 %s 的事件出错", run_id, exc_info=True)
# ---------------- 执行的一方 ----------------
def _on_watch(self, parts: list[str]) -> None:
if not parts or ":" not in parts[0]:
return
kind, target = parts[0].split(":", 1)
until = time.monotonic() + WATCH_TTL_S
if kind == "r":
self._watched_runs[target] = until
elif kind == "t":
self._watched_turns[target] = until
def _watched(self, book: dict[str, float], key: str) -> bool:
until = book.get(key)
if until is None:
return False
if until < time.monotonic():
book.pop(key, None)
return False
return True
def turn_watched(self, turn_id: str) -> bool:
return signals.active and self._watched(self._watched_turns, turn_id)
def _tap(self, event: RunEventModel) -> None:
if not signals.active:
return
if event.type in EPHEMERAL:
if self._watched(self._watched_runs, event.run_id):
self._buffer.setdefault(event.run_id, []).append(event.model_dump(mode="json"))
if self._flusher is None or self._flusher.done():
self._flusher = asyncio.ensure_future(self._flush())
return
if event.run_id in self._follow:
self._cursor[event.run_id] = max(self._cursor.get(event.run_id, 0), int(event.seq or 0))
self._seen.setdefault(event.run_id, set()).add(int(event.seq or 0))
signals.publish("r", event.run_id, event.seq)
async def _flush(self) -> None:
await asyncio.sleep(EPHEMERAL_FLUSH_S)
buffer, self._buffer = self._buffer, {}
for run_id, items in buffer.items():
for chunk in _chunks(items):
signals.publish("e", run_id, json.dumps(chunk, ensure_ascii=False, default=str), keep=True)
def beat(self, turn_id: str, op: dict[str, Any]) -> None:
"""问数据助手的一拍保活心跳:有别的进程在看这一轮就转过去。"""
if self.turn_watched(turn_id):
text = json.dumps(op, ensure_ascii=False, default=str)
if len(text.encode()) <= PAYLOAD_MAX:
signals.publish("b", turn_id, text)
def _size(item: dict[str, Any]) -> int:
return len(json.dumps(item, ensure_ascii=False, default=str).encode())
def _split(item: dict[str, Any], limit: int) -> list[dict[str, Any]]:
"""太长的一条正文增量按字切成几条(复核 R-b6:以前截掉一截):按字切,不会切坏 UTF-8;每条都在 limit 字节以内。切不开的
(不是正文增量)交回空。"""
data = dict(item.get("data") or {})
text = data.get("delta")
if not isinstance(text, str) or set(data) != {"delta"}:
return []
out: list[dict[str, Any]] = []
while text:
low, high = 1, len(text)
while low < high: # 二分:最长的一段前缀,装得进 limit
mid = (low + high + 1) // 2
if _size({**item, "data": {"delta": text[:mid]}}) <= limit:
low = mid
else:
high = mid - 1
out.append({**item, "data": {"delta": text[:low]}})
text = text[low:]
return out
def _chunks(items: list[dict[str, Any]]) -> Iterator[list[dict[str, Any]]]:
"""按载荷上限切开(同一个节点接着的正文增量先合成一条;合成之后太长的再按字切成几条,不截)。"""
merged: list[dict[str, Any]] = []
for item in items:
prev = merged[-1] if merged else None
if prev is not None and prev.get("type") == item.get("type") and prev.get("node_id") == item.get("node_id") \
and set((prev.get("data") or {})) == {"delta"} == set((item.get("data") or {})):
prev["data"] = {"delta": str(prev["data"]["delta"]) + str(item["data"]["delta"])}
prev["seq"] = item.get("seq", prev.get("seq"))
continue
merged.append(dict(item))
limit = PAYLOAD_MAX - 2
pieces: list[dict[str, Any]] = []
for item in merged:
if _size(item) <= limit:
pieces.append(item)
else:
pieces.extend(_split(item, limit)) # 不是正文增量又太长的(不该有)丢掉,丢了无妨:不落库的本来就不进回放
chunk: list[dict[str, Any]] = []
size = 2
for item in pieces:
n = _size(item)
if chunk and size + n + 1 > PAYLOAD_MAX:
yield chunk
chunk, size = [], 2
chunk.append(item)
size += n + 1
if chunk:
yield chunk
def _quiet(task: asyncio.Future[Any]) -> None:
if not task.cancelled() and task.exception() is not None:
logger.debug("转发出错", exc_info=task.exception())
relay = Relay()
__all__ = ["EPHEMERAL", "POLL_S", "Relay", "WATCH_S", "relay"]