✨ feat: WP-05 模型网关核心——逻辑模型、流式适配与 Run 真实执行

- 迁移 7+8:logical_model(能力目录)、provider_endpoint(密钥只存环境变量名,永不落库)、turn、model_call(每 attempt 单独持久化:usage/TTFT/错误,Provider 未返回保持 null 不估算——ADR-003);message.run_id 关联
- OpenAI-compatible 流式适配器:httpx SSE 解析、标准化 StreamChunk(delta/usage/done)、transport 可注入(测试与故障注入);密钥经环境变量读取不进日志
- Run 执行器:后台执行完整生命周期——running → content.block.started → 真实 content.block.delta*(流式产生,delta_index 单调)→ content.block.completed → message.completed → 终态;assistant 消息为权威快照;ModelCall 记录 usage 与 TTFT;无可用端点时 Run 显式失败并留 error 事件(fail-closed)
- post_message:响应提交后由框架 BackgroundTasks 执行 Run(显式预提交保证 Run 落库);SSE 改为增量轮询(重放 + 等待新事件至终态,30s 上限)
- 测试 54 项全绿(+2):端到端 mock 流验证 ModelCall usage/TTFT/assistant 权威消息、无端点 Run failed + content.block.failed 事件
This commit is contained in:
chenxin committed 2026-09-11 09:44:54 +08:00
1 parent 40221170ae
commit b4242f3844
10 files changed
+789 -56

No files matched your search

+42 -13
View File
@@ -4,7 +4,7 @@ import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
@@ -51,6 +51,7 @@ async def post_message(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
background_tasks: BackgroundTasks,
body: MessageIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
) -> MessageCreatedOut | JSONResponse:
@@ -90,6 +91,17 @@ async def post_message(
if replayed:
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
if run is not None:
# 先显式提交:确保 Run 在后台任务执行前已落库(与依赖清理顺序解耦)
await db.commit()
from app.gateway import execute_run
background_tasks.add_task(
execute_run,
request.app.state.session_factory,
run.id,
transport=getattr(request.app.state, "gw_transport", None),
)
return payload
@@ -111,23 +123,40 @@ async def run_events(
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
import asyncio as _asyncio
from app.models import RunEvent
events = (
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
terminal = {"completed", "failed", "cancelled"}
async def event_stream() -> AsyncIterator[str]:
for event in events:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
last_seq = -1
deadline = _asyncio.get_event_loop().time() + 30.0
while True:
rows = list(
(
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id, RunEvent.sequence > last_seq)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
)
yield f"data: {payload}\n\n"
for event in rows:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
last_seq = event.sequence
if not rows:
await db.rollback() # 释放快照,下一轮可见新写入
await db.refresh(run)
if run.status in terminal:
break
if _asyncio.get_event_loop().time() > deadline:
break
await _asyncio.sleep(0.05)
return StreamingResponse(
event_stream(),
+3 -35
View File
@@ -24,7 +24,6 @@ from app.models import (
Message,
OutboxEvent,
Run,
RunEvent,
)
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
@@ -157,20 +156,19 @@ async def replay_or_execute_message(
run = None
if body.start_run:
# 垂直切片空状态闭环:不调用模型,Run 原子创建并直接进入终态 completed。
# Run 创建为 queued,由网关执行器在后台推进至终态(WP-05)
run = Run(
personal_domain_id=domain_id,
conversation_id=conversation.id,
status="completed",
completed_at=datetime.now(UTC),
status="queued",
)
db.add(run)
await db.flush()
message.run_id = run.id
resource_ids: dict[str, Any] = {"message_id": message.id}
if run is not None:
resource_ids["run_id"] = run.id
_record_run_events(db, run, message, blocks)
db.add(
AuditEvent(
actor_type="user",
@@ -203,36 +201,6 @@ async def replay_or_execute_message(
return message, run, False
def _record_run_events(
db: AsyncSession, run: Run, message: Message, blocks: list[dict[str, Any]]
) -> None:
"""空状态闭环的事件流:状态事件 + 每 block completed + message.completed。
sequence 在 Run 内从 0 单调;事件与权威状态同事务提交(契约第 5 节)。
delta/progress 事件在模型流式接入(WP-05)后由真实流产生,此处不伪造。
"""
seq = 0
events: list[tuple[str, dict[str, Any]]] = [
("run.status.changed", {"run_id": run.id, "status": run.status})
]
for index, block in enumerate(blocks):
events.append(
(
"content.block.completed",
{"run_id": run.id, "message_id": message.id, "index": index,
"block": {"id": block["id"], "type": block["type"], "status": "completed"}},
)
)
events.append(
(
"message.completed",
{"run_id": run.id, "message_ids": [message.id], "final_status": run.status},
)
)
for event_type, data in events:
db.add(RunEvent(run_id=run.id, sequence=seq, type=event_type, data=data))
seq += 1
async def list_messages(
db: AsyncSession,
+6
View File
@@ -0,0 +1,6 @@
"""模型网关:路由与 Run 执行(WP-05)。"""
from .adapters import AdapterRequest, GatewayError, stream_chat
from .runner import execute_run
__all__ = ["AdapterRequest", "GatewayError", "stream_chat", "execute_run"]
+107
View File
@@ -0,0 +1,107 @@
"""OpenAI-compatible 流式适配器(WP-05)。
- 只消费逻辑请求(messages + 逻辑模型),输出标准化的流事件与 usage。
- 密钥从环境变量读取(endpoint.api_key_env),不落库、不进日志。
- Provider 未返回的 usage 字段保持 None(ADR-003:不估算伪装)。
- transport 可注入,供测试与故障注入。
"""
import json
import os
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import httpx
class GatewayError(RuntimeError):
"""网关错误:稳定分类,供 ModelCall 失败记录与上层重试决策。"""
@dataclass
class StreamChunk:
kind: str # "delta" | "usage" | "done"
text: str | None = None
usage: dict[str, int] | None = None
finish_reason: str | None = None
@dataclass
class AdapterRequest:
messages: list[dict[str, str]] # {"role", "content"}:纯文本最小集
model: str # 上游模型 ID
def _parse_sse_line(line: str) -> dict[str, Any] | None:
if not line.startswith("data:"):
return None
payload = line.removeprefix("data:").strip()
if not payload or payload == "[DONE]":
return None
try:
parsed: dict[str, Any] = json.loads(payload)
return parsed
except json.JSONDecodeError:
return None
def _usage_from_final(chunk: dict[str, Any]) -> dict[str, int] | None:
usage = chunk.get("usage")
if not isinstance(usage, dict):
return None
result: dict[str, int] = {}
keys = (
"prompt_tokens",
"completion_tokens",
"total_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
)
for key in keys:
value = usage.get(key)
if isinstance(value, int):
result[key] = value
return result or None
async def stream_chat(
base_url: str,
model_id: str,
api_key_env: str,
request: AdapterRequest,
*,
request_timeout_s: float = 120.0,
transport: httpx.AsyncBaseTransport | None = None,
) -> AsyncIterator[StreamChunk]:
"""流式调用 OpenAI-compatible /chat/completions,产出标准化块。"""
api_key = os.environ.get(api_key_env, "")
if not api_key:
raise GatewayError(f"环境变量 {api_key_env} 未配置 Provider 密钥")
url = base_url.rstrip("/") + "/chat/completions"
body = {
"model": model_id,
"messages": request.messages,
"stream": True,
"stream_options": {"include_usage": True},
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=request_timeout_s, transport=transport) as client:
async with client.stream("POST", url, json=body, headers=headers) as resp:
if resp.status_code != 200:
detail = (await resp.aread()).decode("utf-8", "replace")[:500]
raise GatewayError(f"上游返回 {resp.status_code}: {detail}")
async for line in resp.aiter_lines():
chunk = _parse_sse_line(line)
if chunk is None:
continue
for choice in chunk.get("choices", []):
text = choice.get("delta", {}).get("content")
if text:
yield StreamChunk(kind="delta", text=text)
finish = choice.get("finish_reason")
if finish:
yield StreamChunk(kind="done", finish_reason=finish)
usage = _usage_from_final(chunk)
if usage is not None:
yield StreamChunk(kind="usage", usage=usage)
+250
View File
@@ -0,0 +1,250 @@
"""Run 执行器:调用模型、产生真实流事件与 assistant 消息(WP-05)。
- 后台任务执行:独立数据库会话(不复用请求会话)。
- 事件序列(契约第 5 节):run.status.changed(running) → content.block.started
→ content.block.delta* → content.block.completed → message.completed →
run.status.changed(completed/failed)。
- 首个用户可见 delta 前的失败允许显式 fail;无可用端点时 Run 失败并留 error 事件。
"""
import time
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.ids import new_id
from app.gateway.adapters import AdapterRequest, GatewayError, stream_chat
from app.models import (
AuditEvent,
Message,
ModelCall,
OutboxEvent,
ProviderEndpoint,
Run,
RunEvent,
Turn,
)
async def _next_sequence(db: AsyncSession, run_id: str) -> int:
last = await db.execute(
select(RunEvent.sequence)
.where(RunEvent.run_id == run_id)
.order_by(RunEvent.sequence.desc())
.limit(1)
)
row = last.scalar_one_or_none()
return (row + 1) if row is not None else 0
def _emit(db: AsyncSession, run_id: str, seq: int, event_type: str, data: dict[str, Any]) -> int:
db.add(RunEvent(run_id=run_id, sequence=seq, type=event_type, data=data))
return seq + 1
async def execute_run(
factory: async_sessionmaker[AsyncSession],
run_id: str,
transport: Any | None = None,
) -> None:
"""执行一个 Run 至终态。transport 注入仅供测试。"""
async with factory() as db:
try:
run = await db.get(Run, run_id)
if run is None:
return
seq = await _next_sequence(db, run_id)
# 状态事件:running
run.status = "running"
seq = _emit(
db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "running"},
)
await db.commit()
# 路由:按逻辑模型选择启用端点(优先级升序)
endpoint = (
await db.execute(
select(ProviderEndpoint)
.where(
ProviderEndpoint.enabled.is_(True),
ProviderEndpoint.logical_model_id == "default-chat",
)
.order_by(ProviderEndpoint.priority.asc())
.limit(1)
)
).scalar_one_or_none()
turn = Turn(run_id=run_id, index=0)
db.add(turn)
await db.flush()
if endpoint is None:
# fail closed:无可用端点时 Run 显式失败,不静默降级
call = ModelCall(
turn_id=turn.id, status="failed",
request_summary={}, error="无可用 Provider 端点(default-chat)",
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
db.add(call)
run.status = "failed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "content.block.failed", {
"run_id": run_id, "message_id": None, "index": 0,
"block": {"id": new_id("block_"), "type": "text", "status": "failed"},
"error": "no_endpoint_available",
})
seq = _emit(db, run_id, seq, "message.completed",
{"run_id": run_id, "message_ids": [], "final_status": "failed"})
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "failed"})
await db.commit()
return
# 对话历史(最小:当前分支文本消息)
messages = (
await db.execute(
select(Message)
.where(
Message.conversation_id == run.conversation_id,
Message.role.in_(["user", "assistant"]),
)
.order_by(Message.created_at.asc(), Message.id.asc())
.limit(50)
)
).scalars().all()
prompt = [
{
"role": m.role,
"content": "".join(
str(b.get("text", b.get("source", "")))
for b in m.content
if isinstance(b, dict)
),
}
for m in messages
]
call = ModelCall(
turn_id=turn.id, endpoint_id=endpoint.id, status="streaming",
request_summary={"messages": len(prompt), "model_id": endpoint.model_id},
started_at=datetime.now(UTC),
)
db.add(call)
await db.commit()
block_id = new_id("block_")
seq = _emit(db, run_id, seq, "content.block.started", {
"run_id": run_id, "index": 0,
"block": {"id": block_id, "type": "text", "status": "streaming"},
})
await db.commit()
# 流式消费:真实 delta 事件
started = time.monotonic()
ttft_ms: int | None = None
full_text: list[str] = []
usage: dict[str, int] | None = None
finish_reason: str | None = None
delta_index = 0
try:
adapter_req = AdapterRequest(messages=prompt, model=endpoint.model_id)
async for chunk in stream_chat(
endpoint.base_url, endpoint.model_id, endpoint.api_key_env,
adapter_req, transport=transport,
):
if chunk.kind == "delta" and chunk.text:
if ttft_ms is None:
ttft_ms = int((time.monotonic() - started) * 1000)
full_text.append(chunk.text)
seq = _emit(db, run_id, seq, "content.block.delta", {
"run_id": run_id, "block_id": block_id,
"delta_index": delta_index,
"delta": {"type": "text_append", "text": chunk.text},
})
delta_index += 1
await db.commit()
elif chunk.kind == "usage" and chunk.usage:
usage = chunk.usage
elif chunk.kind == "done" and chunk.finish_reason:
finish_reason = chunk.finish_reason
except GatewayError as exc:
failed_call = await db.get(ModelCall, call.id)
assert failed_call is not None
call.status = "failed"
call.error = str(exc)[:500]
call.completed_at = datetime.now(UTC)
run.status = "failed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "content.block.failed", {
"run_id": run_id, "block_id": block_id, "index": 0,
"error": "upstream_error",
})
seq = _emit(db, run_id, seq, "message.completed",
{"run_id": run_id, "message_ids": [], "final_status": "failed"})
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "failed"})
await db.commit()
return
text = "".join(full_text)
seq = _emit(db, run_id, seq, "content.block.completed", {
"run_id": run_id, "index": 0,
"block": {"id": block_id, "type": "text", "status": "completed", "text": text},
})
# assistant 消息(权威快照)
assistant = Message(
conversation_id=run.conversation_id,
branch_id=(
await db.execute(
select(Message.branch_id).where(Message.run_id == run_id).limit(1)
)
).scalar_one_or_none() or new_id("branch_"),
role="assistant",
realm="reality",
content=[{"id": block_id, "type": "text", "text": text, "status": "completed"}],
run_id=run_id,
)
db.add(assistant)
await db.flush()
seq = _emit(db, run_id, seq, "message.completed", {
"run_id": run_id, "message_ids": [assistant.id], "final_status": "completed",
})
run.status = "completed"
run.completed_at = datetime.now(UTC)
seq = _emit(db, run_id, seq, "run.status.changed",
{"run_id": run_id, "status": "completed"})
success_call = await db.get(ModelCall, call.id)
assert success_call is not None
success_call.status = "succeeded"
# Provider 未返回的 usage 保持 None:不估算伪装(ADR-003)
success_call.usage = usage
success_call.ttft_ms = ttft_ms
success_call.completed_at = datetime.now(UTC)
if finish_reason:
success_call.request_summary = {
**success_call.request_summary, "finish_reason": finish_reason
}
db.add(
AuditEvent(
actor_type="system", actor_id="gateway", action="model_call.completed",
aggregate_type="run", aggregate_id=run_id,
payload={"endpoint": endpoint.name, "usage": usage, "ttft_ms": ttft_ms},
)
)
db.add(
OutboxEvent(
aggregate_type="message", aggregate_id=assistant.id,
event_type="message.created",
payload={"message_id": assistant.id, "run_id": run_id, "role": "assistant"},
)
)
await db.commit()
except Exception:
await db.rollback()
raise
+98
View File
@@ -207,6 +207,9 @@ class Message(Base):
ForeignKey("message.id", ondelete="set null"), nullable=True
)
variant_kind: Mapped[str] = mapped_column(String, nullable=False, default="origin")
run_id: Mapped[str | None] = mapped_column(
ForeignKey("run.id", ondelete="set null"), nullable=True, index=True
)
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
@@ -374,3 +377,98 @@ class Character(Base):
name="origin_values",
),
)
class LogicalModel(Base):
"""逻辑模型:业务只引用能力与逻辑名,不感知 Provider(契约第 5 节分面)。"""
__tablename__ = "logical_model"
id: Mapped[str] = mapped_column(String, primary_key=True) # kebab-case,如 default-chat
capabilities: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
description: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class ProviderEndpoint(Base):
"""Provider 端点:密钥只存环境变量名,永不落库明文。"""
__tablename__ = "provider_endpoint"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("endpoint_")
)
logical_model_id: Mapped[str] = mapped_column(
ForeignKey("logical_model.id", ondelete="cascade"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
base_url: Mapped[str] = mapped_column(String, nullable=False)
model_id: Mapped[str] = mapped_column(String, nullable=False)
api_key_env: Mapped[str] = mapped_column(String, nullable=False)
priority: Mapped[int] = mapped_column(nullable=False, default=100)
enabled: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"provider IN ('openai_compatible', 'anthropic')", name="provider_values"
),
)
class Turn(Base):
"""Turn:Run 内一次模型决策及其工具交互阶段。"""
__tablename__ = "turn"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("turn_"))
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True
)
index: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("uq_turn_run_index", "run_id", "index", unique=True),
)
class ModelCall(Base):
"""每次上游 attempt 单独持久化:重试/故障转移不覆盖(ADR-003)。
Provider 未返回的 usage 字段保持 null,不估算伪装。
"""
__tablename__ = "model_call"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("model_call_")
)
turn_id: Mapped[str] = mapped_column(
ForeignKey("turn.id", ondelete="cascade"), nullable=False, index=True
)
endpoint_id: Mapped[str | None] = mapped_column(
ForeignKey("provider_endpoint.id", ondelete="set null"), nullable=True
)
attempt: Mapped[int] = mapped_column(nullable=False, default=1)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
request_summary: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
usage: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
ttft_ms: Mapped[int | None] = mapped_column(nullable=True)
error: Mapped[str | None] = mapped_column(String, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'streaming', 'succeeded', 'failed')", name="status_values"
),
Index("uq_model_call_turn_attempt", "turn_id", "attempt", unique=True),
)