✨ 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:
1 parent
40221170ae
commit
b4242f3844
10 files changed
+789
-56
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user