M0:灵台 Kernel 骨架(事件溯源/命台席位/瀑布总线/插件注册)与神识客户端边界,新增 POST /runs 占位闭环与测试,Alembic 基线合并为单一起点
This commit is contained in:
1 parent
99e55fa0a8
commit
7ef3a15061
23 files changed
+2014
-1399
No files matched your search
@@ -0,0 +1,78 @@
|
||||
"""极小硬核 Kernel:不变量裁决、命台并发控制、事件日志写入、Run 循环骨架。
|
||||
|
||||
不可插拔(lingtai-runtime.md §1);插件与 Profile 见 ``app.runtime``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.kernel.eventlog import KernelEventLog
|
||||
from app.kernel.loop import KernelRunLoop
|
||||
from app.kernel.seat import SeatManager
|
||||
from app.models.runtime import Run
|
||||
from app.runtime.bus import WaterfallBus
|
||||
from app.runtime.profile import resolve_profile
|
||||
from app.shenshi_client.client import ShenshiClient
|
||||
|
||||
__all__ = ["Kernel", "KernelEventLog", "KernelRunLoop", "SeatManager"]
|
||||
|
||||
|
||||
class Kernel:
|
||||
"""门面:持有 Kernel 全部部件,供 lifespan 装配与 API 层调用。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
shenshi: ShenshiClient,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
event_log: KernelEventLog,
|
||||
bus: WaterfallBus | None = None,
|
||||
seats: SeatManager | None = None,
|
||||
timeout_seconds: float = 120.0,
|
||||
) -> None:
|
||||
self._sessions = session_factory
|
||||
self.bus = bus or WaterfallBus()
|
||||
self.seats = seats or SeatManager()
|
||||
self.loop = KernelRunLoop(
|
||||
event_log=event_log,
|
||||
seats=self.seats,
|
||||
bus=self.bus,
|
||||
shenshi=shenshi,
|
||||
session_factory=session_factory,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
self._tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def start_foreground_run(
|
||||
self, *, character_id: str, prompt: str, trace_id: str | None = None
|
||||
) -> Run:
|
||||
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
|
||||
profile = resolve_profile()
|
||||
async with self._sessions() as session:
|
||||
run = Run(
|
||||
character_id=character_id,
|
||||
session_id=None,
|
||||
status="queued",
|
||||
goal=prompt[:300] if prompt else None,
|
||||
origin="kernel",
|
||||
model_hint=str(profile.params.get("slot", "chat")),
|
||||
)
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
task = asyncio.create_task(
|
||||
self.loop.execute(
|
||||
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace_id
|
||||
)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async with self._sessions() as session:
|
||||
created = await session.get(Run, run_id)
|
||||
assert created is not None
|
||||
return created
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Kernel 事件日志:一炁(EventStore)之上的 Kernel 写入接口。
|
||||
|
||||
- 落库走 ``DbEventStore``(run 内单调 sequence,DB 为事实源,SSE 断线重放依据);
|
||||
- 双发进程内 ``bus``(RunEventBus)供既有 ``/runs/{id}/stream`` SSE 实时转发。
|
||||
该双发是 M0 骨架的过渡接法,M2 重建 runs 路由时收敛为 EventStore 单通道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.runtime.events import Event, EventStore
|
||||
from app.schemas.runtime import EventResponse
|
||||
from app.services.event_bus import bus
|
||||
|
||||
|
||||
class KernelEventLog:
|
||||
"""Kernel 视角的事件写入与检索。"""
|
||||
|
||||
def __init__(self, store: EventStore) -> None:
|
||||
self._store = store
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
*,
|
||||
type: str, # noqa: A002 — 对齐事件字段名
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
source: str = "kernel",
|
||||
payload: dict[str, Any] | None = None,
|
||||
visibility: str = "public",
|
||||
trace_id: str | None = None,
|
||||
) -> Event:
|
||||
event = await self._store.append(
|
||||
Event(
|
||||
type=type,
|
||||
source=source,
|
||||
payload=payload or {},
|
||||
visibility=visibility, # type: ignore[arg-type]
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
bus.publish(run_id, _to_response(event))
|
||||
return event
|
||||
|
||||
async def list_by_run(self, run_id: str) -> list[Event]:
|
||||
return await self._store.list_by_run(run_id)
|
||||
|
||||
|
||||
def _to_response(event: Event) -> EventResponse:
|
||||
return EventResponse(
|
||||
event_id=event.id,
|
||||
run_id=event.run_id or "",
|
||||
character_id=event.character_id or "",
|
||||
type=event.type,
|
||||
sequence=event.sequence if event.sequence is not None else 0,
|
||||
visibility=event.visibility,
|
||||
payload=event.payload,
|
||||
created_at=event.created_at,
|
||||
trace_id=event.trace_id,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Kernel 不可插拔不变量(lingtai-runtime.md §1 硬核例外)。
|
||||
|
||||
M0 落地其中两条的可执行断言:
|
||||
- "模型可见即可重建":投影中的每条模型可见消息必须能在事件日志(llm.requested)中找到;
|
||||
- 流一致性:llm.delta 事件重组文本必须与进入接纳检查的文本一致。
|
||||
|
||||
镜树不变量(重生成不覆盖/未归真不入忆/归真不回滚)随 M2 镜树域落地时进入本模块;
|
||||
禁域裁决点随 M5 司命落地。断言失败即抛 ``InvariantViolation``:Kernel 视其为缺陷
|
||||
而非业务错误,Run 进入 interrupted 并带 HJ-9000 段错误码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.runtime.events import Event
|
||||
from app.shenshi_client.types import ShenshiChatRequest
|
||||
|
||||
|
||||
class InvariantViolation(Exception):
|
||||
"""Kernel 不变量被违反:缺陷信号,必须中断并留痕。"""
|
||||
|
||||
def __init__(self, invariant: str, detail: str) -> None:
|
||||
super().__init__(f"[{invariant}] {detail}")
|
||||
self.invariant = invariant
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def assert_model_visible_logged(
|
||||
request: ShenshiChatRequest, events: list[Event]
|
||||
) -> None:
|
||||
"""投影消息 ⊆ 已记录的 llm.requested 消息("模型可见即可重建")。"""
|
||||
requested = next((e for e in events if e.type == "llm.requested"), None)
|
||||
if requested is None:
|
||||
raise InvariantViolation(
|
||||
"model-visible-means-logged", "存在模型请求但事件日志缺少 llm.requested"
|
||||
)
|
||||
logged = requested.payload.get("request", {}).get("messages", [])
|
||||
if logged != [m.model_dump() for m in request.messages]:
|
||||
raise InvariantViolation(
|
||||
"model-visible-means-logged", "llm.requested 事件与实际发送的 IR 消息不一致"
|
||||
)
|
||||
|
||||
|
||||
def assert_stream_reconstruction(events: list[Event], accepted_text: str) -> None:
|
||||
"""llm.delta 重组文本 == 进入接纳的文本(流不丢帧、不重帧)。"""
|
||||
reconstructed = "".join(
|
||||
e.payload.get("delta", "") for e in events if e.type == "llm.delta"
|
||||
)
|
||||
if reconstructed != accepted_text:
|
||||
raise InvariantViolation(
|
||||
"stream-reconstruction",
|
||||
f"事件重组文本与接纳文本不一致:{len(reconstructed)} != {len(accepted_text)} 字符",
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Kernel Run 循环骨架(M0 占位形态)。
|
||||
|
||||
事件序列对应 docs/architecture/lingtai-runtime.md §3.1;瀑布节点可被插件否决。
|
||||
M0 的投影为固定占位(系统契约占位 + 用户输入),M2 起接入镜树祖先路径、获准记忆
|
||||
与显式引文三类来源。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.ids import new_run_id, new_trace_id
|
||||
from app.kernel.eventlog import KernelEventLog
|
||||
from app.kernel.invariants import (
|
||||
InvariantViolation,
|
||||
assert_model_visible_logged,
|
||||
assert_stream_reconstruction,
|
||||
)
|
||||
from app.kernel.seat import SeatManager
|
||||
from app.models.runtime import Run
|
||||
from app.runtime.bus import (
|
||||
NODE_LLM_PRE_REQUEST,
|
||||
NODE_RESULT_PRE_ACCEPT,
|
||||
NODE_RUN_REQUESTED,
|
||||
NODE_TURN_STARTED,
|
||||
RunContext,
|
||||
Veto,
|
||||
WaterfallBus,
|
||||
)
|
||||
from app.runtime.profile import Profile, resolve_profile
|
||||
from app.shenshi_client.client import ShenshiClient
|
||||
from app.shenshi_client.types import ShenshiMessage, ShenshiChatRequest
|
||||
|
||||
_SYSTEM_CONTRACT_PLACEHOLDER = "你是魂镜人物运行时(M0 骨架占位契约)。"
|
||||
|
||||
|
||||
class KernelRunLoop:
|
||||
"""单次 Run 的执行骨架:命台席位 → 事件序列 → 神识流 → 接纳断言。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
event_log: KernelEventLog,
|
||||
seats: SeatManager,
|
||||
bus: WaterfallBus,
|
||||
shenshi: ShenshiClient,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
self._log = event_log
|
||||
self._seats = seats
|
||||
self._bus = bus
|
||||
self._shenshi = shenshi
|
||||
self._sessions = session_factory
|
||||
self._timeout = timeout_seconds
|
||||
|
||||
async def execute(
|
||||
self, *, run_id: str, character_id: str, prompt: str, trace_id: str | None = None
|
||||
) -> None:
|
||||
trace = trace_id or new_trace_id()
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout):
|
||||
await self._execute(
|
||||
run_id=run_id, character_id=character_id, prompt=prompt, trace_id=trace
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._finish_interrupted(
|
||||
run_id, character_id, trace, code="run_timeout", message="运行超时被中断"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await self._emit(run_id, character_id, trace, "run.cancelled", visibility="public")
|
||||
await self._update_run(run_id, status="cancelled")
|
||||
raise
|
||||
|
||||
async def _execute(
|
||||
self, *, run_id: str, character_id: str, prompt: str, trace_id: str
|
||||
) -> None:
|
||||
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
|
||||
try:
|
||||
await self._bus.fire(NODE_RUN_REQUESTED, ctx)
|
||||
except Veto as veto:
|
||||
await self._record_veto(run_id, character_id, trace_id, veto)
|
||||
await self._finish_interrupted(
|
||||
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
|
||||
)
|
||||
return
|
||||
|
||||
await self._emit(run_id, character_id, trace_id, "run.queued")
|
||||
async with self._seats.foreground(character_id):
|
||||
await self._update_run(run_id, status="running")
|
||||
await self._emit(run_id, character_id, trace_id, "run.started")
|
||||
await self._turn(run_id, character_id, trace_id, prompt)
|
||||
|
||||
async def _turn(
|
||||
self, run_id: str, character_id: str, trace_id: str, prompt: str
|
||||
) -> None:
|
||||
ctx = RunContext(
|
||||
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
|
||||
)
|
||||
profile: Profile = resolve_profile()
|
||||
await self._emit(run_id, character_id, trace_id, "turn.started")
|
||||
await self._bus.fire(NODE_TURN_STARTED, ctx)
|
||||
|
||||
request = ShenshiChatRequest(
|
||||
model=profile.params.get("model"),
|
||||
slot=str(profile.params.get("slot", "chat")),
|
||||
messages=[
|
||||
ShenshiMessage(role="system", content=_SYSTEM_CONTRACT_PLACEHOLDER),
|
||||
ShenshiMessage(role="user", content=prompt),
|
||||
],
|
||||
run_id=run_id,
|
||||
trace_id=trace_id,
|
||||
character_id=character_id,
|
||||
)
|
||||
await self._emit(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
"context.projected",
|
||||
payload={
|
||||
"profile": profile.snapshot(),
|
||||
"sources": {"system_contract": "placeholder", "user_input": True},
|
||||
},
|
||||
visibility="internal",
|
||||
)
|
||||
try:
|
||||
await self._bus.fire(NODE_LLM_PRE_REQUEST, ctx)
|
||||
except Veto as veto:
|
||||
await self._record_veto(run_id, character_id, trace_id, veto)
|
||||
await self._finish_interrupted(
|
||||
run_id, character_id, trace_id, code="HJ-1301", message=veto.reason
|
||||
)
|
||||
return
|
||||
|
||||
# "模型可见即可重建"锚点:完整 IR 请求进事件日志
|
||||
await self._emit(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
"llm.requested",
|
||||
payload={"request": request.model_dump(mode="json")},
|
||||
visibility="internal",
|
||||
)
|
||||
|
||||
text_parts: list[str] = []
|
||||
usage: dict[str, Any] = {}
|
||||
try:
|
||||
async for delta in self._shenshi.stream_chat(request):
|
||||
if delta.delta:
|
||||
text_parts.append(delta.delta)
|
||||
await self._emit(
|
||||
run_id, character_id, trace_id, "llm.delta", payload={"delta": delta.delta}
|
||||
)
|
||||
if delta.finish_reason:
|
||||
usage = {
|
||||
"prompt_tokens": delta.usage_prompt_tokens,
|
||||
"completion_tokens": delta.usage_completion_tokens,
|
||||
"finish_reason": delta.finish_reason,
|
||||
}
|
||||
except Exception as exc: # 神识侧失败:如实上报,不伪装完成
|
||||
await self._emit(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
"llm.failed",
|
||||
payload={"error": str(exc)},
|
||||
visibility="internal",
|
||||
)
|
||||
await self._finish_interrupted(
|
||||
run_id, character_id, trace_id, code="HJ-2001", message=f"神识模型调用失败:{exc}"
|
||||
)
|
||||
return
|
||||
|
||||
await self._emit(run_id, character_id, trace_id, "llm.completed", payload={"usage": usage})
|
||||
|
||||
accepted_text = "".join(text_parts)
|
||||
await self._bus.fire(
|
||||
NODE_RESULT_PRE_ACCEPT, replace(ctx, payload={"result": accepted_text})
|
||||
)
|
||||
events = await self._log.list_by_run(run_id)
|
||||
try:
|
||||
assert_model_visible_logged(request, events)
|
||||
assert_stream_reconstruction(events, accepted_text)
|
||||
except InvariantViolation as violation:
|
||||
await self._emit(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
"run.invariant_violation",
|
||||
payload={"invariant": violation.invariant, "detail": violation.detail},
|
||||
visibility="audit",
|
||||
)
|
||||
await self._finish_interrupted(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
code="HJ-9000",
|
||||
message=f"Kernel 不变量违反:{violation.invariant}",
|
||||
)
|
||||
return
|
||||
|
||||
await self._emit(
|
||||
run_id, character_id, trace_id, "result.accepted", payload={"content": accepted_text}
|
||||
)
|
||||
await self._emit(run_id, character_id, trace_id, "turn.finished")
|
||||
await self._emit(run_id, character_id, trace_id, "run.finished")
|
||||
await self._update_run(run_id, status="completed")
|
||||
|
||||
async def _record_veto(
|
||||
self, run_id: str, character_id: str, trace_id: str, veto: Veto
|
||||
) -> None:
|
||||
await self._emit(
|
||||
run_id,
|
||||
character_id,
|
||||
trace_id,
|
||||
"safety.ruled",
|
||||
payload={"reason": veto.reason, "rule": veto.rule, "details": veto.details},
|
||||
visibility="audit",
|
||||
)
|
||||
|
||||
async def _finish_interrupted(
|
||||
self,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
trace_id: str,
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
await self._emit(run_id, character_id, trace_id, "run.interrupted")
|
||||
await self._update_run(run_id, status="interrupted", error_code=code, error_message=message)
|
||||
|
||||
async def _emit(
|
||||
self,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
trace_id: str,
|
||||
type: str, # noqa: A002
|
||||
payload: dict[str, Any] | None = None,
|
||||
visibility: str = "public",
|
||||
) -> None:
|
||||
await self._log.emit(
|
||||
type=type,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
payload=payload,
|
||||
visibility=visibility,
|
||||
)
|
||||
|
||||
async def _update_run(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
status: str,
|
||||
error_code: str | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
async with self._sessions() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
return
|
||||
run.status = status
|
||||
run.error_code = error_code
|
||||
run.error_message = error_message
|
||||
if status == "running":
|
||||
run.started_at = datetime.now(UTC)
|
||||
if status in ("completed", "failed", "cancelled", "interrupted"):
|
||||
run.completed_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def new_kernel_run_id() -> str:
|
||||
return new_run_id()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""命台席位控制:同一人物同一时刻只有一条前台意识流。
|
||||
|
||||
M0 为进程内实现(单人物粒度 asyncio 锁 + 等待计数);多副本部署在标准档
|
||||
切换 Redis 租约(lingtai-runtime.md §8),接口保持不变。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
import asyncio
|
||||
|
||||
|
||||
class SeatManager:
|
||||
"""人物粒度的前台席位。后台受托 Run(kind=background_task)不占席位。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
self._waiting: dict[str, int] = defaultdict(int)
|
||||
|
||||
def _lock(self, character_id: str) -> asyncio.Lock:
|
||||
if character_id not in self._locks:
|
||||
self._locks[character_id] = asyncio.Lock()
|
||||
return self._locks[character_id]
|
||||
|
||||
@asynccontextmanager
|
||||
async def foreground(self, character_id: str) -> AsyncIterator[None]:
|
||||
"""占用前台席位;同人物并发请求在此排队(Kernel 不变量 2)。"""
|
||||
lock = self._lock(character_id)
|
||||
if lock.locked():
|
||||
self._waiting[character_id] += 1
|
||||
try:
|
||||
async with lock:
|
||||
yield
|
||||
finally:
|
||||
self._waiting[character_id] -= 1
|
||||
else:
|
||||
async with lock:
|
||||
yield
|
||||
|
||||
def queue_depth(self, character_id: str) -> int:
|
||||
"""该人物当前排队等待前台席位的请求数(HJ-1101 附带信息)。"""
|
||||
return self._waiting.get(character_id, 0)
|
||||
Reference in new issue
Block a user