perf(roundtable): 轻量 thread 接口降低广播与席位状态核对开销

新增 messages/append 与 last-ai-message 接口,圆桌广播和总控派活轮改走服务端轻量读写,避免 loopback 传输整份 state。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-07 21:20:58 +08:00
1 parent 924c8f2fac
commit 1ee2e6ca0a
2 files changed
+191 -50

No files matched your search

@@ -469,22 +469,17 @@ async def _append_thread_message(
失败只打 warning,不阻断主流程——广播是 best-effort,席位真正干活仍靠
前端对该席位 thread 发起的 ``/run/stream``。
走**轻量追加**接口(``POST /messages/append``):只传一条消息,服务端读 checkpoint+追加+写回,
省掉原先 GET /state(下载全量)+POST /state(上传全量)把几万字状态经 loopback 传两遍的开销。
"""
state_url = f"{base}/api/threads/{thread_id}/state"
get_resp = await client.get(state_url, headers=headers)
if get_resp.status_code != 200:
logger.warning("get state %s failed: %s %s", thread_id, get_resp.status_code, get_resp.text[:200])
return
state = get_resp.json()
values = state.setdefault("values", {})
messages = values.setdefault("messages", [])
if not isinstance(messages, list):
messages = []
values["messages"] = messages
messages.append({"type": "human", "content": content})
post_resp = await client.post(state_url, json=state, headers=headers)
if post_resp.status_code >= 400:
logger.warning("update state %s failed: %s %s", thread_id, post_resp.status_code, post_resp.text[:200])
resp = await client.post(
f"{base}/api/threads/{thread_id}/messages/append",
json={"content": content, "type": "human"},
headers=headers,
)
if resp.status_code >= 400:
logger.warning("append message %s failed: %s %s", thread_id, resp.status_code, resp.text[:200])
def _spawn_detached_broadcast(
@@ -1129,34 +1124,38 @@ async def _collect_delivery_status(
base: str,
headers: dict[str, str],
seat_threads: dict[str, str],
) -> dict[str, tuple[bool, str]]:
*,
max_chars: int = 0,
) -> dict[str, tuple[bool, str, int]]:
"""读各席位 thread 的真实状态,客观判定「是否已交付 + 交付摘要」。
返回 ``{seat_id: (delivered, full_text)}``。``full_text`` 是该席位**完整交付正文**
(调用方自行决定截多少:摘要块截短、综合轮全文)。判定依据:席位 thread 的对话历史里
**是否存在该席位自己产出的、非空的 AI 消息**(它的交付正文)。只有 broadcast 进来的
human 消息(派活通知 / 别的席位的交付摘要)不算它交付。
返回 ``{seat_id: (delivered, text, full_len)}``。``text`` 是该席位**最后一条 AI 交付**(按
``max_chars`` 截断:派活轮取 600 字摘要、综合轮 ``max_chars<=0`` 取全文);``full_len`` 是交付
全文长度(供调用方判断 text 是否被截断、要不要加省略号)。判定依据:席位 thread 是否存在非空
AI 交付,只有 broadcast 进来的 human 消息不算。
这是「确定性双保险」的数据源:总控弱模型会臆造「三方已交付」,这里由网关**客观核对**
每个席位 thread 的实际产出,再把结论喂回总控输入(见 ``_build_seat_status_block``),
总控以系统核对为准,不靠自己读历史猜。任一席位读失败按「未交付」保守处理,绝不阻断。
走**轻量读**接口(``GET /last-ai-message?max_chars=N``):服务端只回最后一条 AI 消息(可截断),
不把整份 state 序列化经 loopback 传回 —— 派活轮每席位只取 600 字,大幅压低原先 ~11s 的
``payload_built``。任一席位读失败按「未交付」保守处理,绝不阻断。
这是「确定性双保险」的数据源:总控弱模型会臆造「三方已交付」,这里由网关**客观核对**每个席位
thread 的实际产出,再把结论喂回总控输入,总控以系统核对为准,不靠自己读历史猜。
"""
async def _one(sid: str, tid: str) -> tuple[str, tuple[bool, str]]:
async def _one(sid: str, tid: str) -> tuple[str, tuple[bool, str, int]]:
try:
resp = await client.get(f"{base}/api/threads/{tid}/state", headers=headers)
resp = await client.get(
f"{base}/api/threads/{tid}/last-ai-message",
params={"max_chars": max_chars},
headers=headers,
)
if resp.status_code != 200:
return sid, (False, "")
messages = ((resp.json().get("values") or {}).get("messages")) or []
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
text = _flatten_content(msg.get("content", "")).strip()
if text:
return sid, (True, text)
return sid, (False, "")
return sid, (False, "", 0)
data = resp.json()
content = str(data.get("content") or "")
full_len = int(data.get("length") or len(content))
return sid, (bool(data.get("delivered")), content, full_len)
except Exception:
return sid, (False, "")
return sid, (False, "", 0)
results = await asyncio.gather(*(_one(sid, tid) for sid, tid in seat_threads.items()))
return dict(results)
@@ -1169,7 +1168,7 @@ _STATUS_EXCERPT_CHARS = 600
def _build_seat_status_block(
seats: list[tuple[str, dict[str, Any]]],
delivery: dict[str, tuple[bool, str]],
delivery: dict[str, tuple[bool, str, int]],
*,
undelivered_count: int = 0,
) -> str:
@@ -1186,11 +1185,11 @@ def _build_seat_status_block(
lines: list[str] = []
for sid, meta in seats:
name = str((meta or {}).get("name") or sid)
delivered, full_text = delivery.get(sid, (False, ""))
delivered, text, full_len = delivery.get(sid, (False, "", 0))
if delivered:
excerpt = full_text[:_STATUS_EXCERPT_CHARS]
ellipsis = "…" if len(full_text) > _STATUS_EXCERPT_CHARS else ""
tail = f" —— 交付摘要:{excerpt}{ellipsis}" if excerpt else ""
# text 已是服务端按 max_chars 截好的摘要;full_len > len(text) 说明被截断,加省略号。
ellipsis = "…" if full_len > len(text) else ""
tail = f" —— 交付摘要:{text}{ellipsis}" if text else ""
lines.append(f"- {sid}({name}):✅ 已交付{tail}")
else:
lines.append(f"- {sid}({name}):⬜ 未交付")
@@ -1217,7 +1216,7 @@ def _build_seat_status_block(
def _build_full_deliverables_block(
seats: list[tuple[str, dict[str, Any]]],
delivery: dict[str, tuple[bool, str]],
delivery: dict[str, tuple[bool, str, int]],
) -> str:
"""综合轮专用:把各已交付席位的**完整交付正文**拼成一块,供总控写最终方案。
@@ -1226,7 +1225,7 @@ def _build_full_deliverables_block(
"""
sections: list[str] = []
for sid, meta in seats:
delivered, full_text = delivery.get(sid, (False, ""))
delivered, full_text, _ = delivery.get(sid, (False, "", 0))
if not delivered or not full_text:
continue
name = str((meta or {}).get("name") or sid)
@@ -1281,22 +1280,32 @@ async def _leader_run(
if seat_meta:
seat_ids = [name for name in agent_threads if name != agent_name]
if seat_ids:
# 第 1 阶段:**轻量**读各席位最后一条 AI 交付,只取 ≤_STATUS_EXCERPT_CHARS 字 ——
# 派活轮只需 ✅/⬜ + 摘要,绝不下载几万字全文,把 payload_built 从 ~11s 压下来。
delivery: dict[str, tuple[bool, str]] = {}
try:
delivery = await _collect_delivery_status(
client, base, headers, {sid: agent_threads[sid] for sid in seat_ids}
client, base, headers, {sid: agent_threads[sid] for sid in seat_ids},
max_chars=_STATUS_EXCERPT_CHARS,
)
except Exception:
logger.warning("[leader-roster] 收集席位交付状态失败,退化为仅席位清单", exc_info=True)
undelivered = [sid for sid in seat_ids if not delivery.get(sid, (False, ""))[0]]
seats_meta_list = [(sid, seat_meta.get(sid, {})) for sid in seat_ids]
seat_status_block = _build_seat_status_block(seats_meta_list, delivery, undelivered_count=len(undelivered))
# 只有「综合轮」**且所有席位都已交付」才注入「完整交付 + 输出最终方案」的综合块。
# 还有 ⬜ 未交付席位时不注入 —— 否则那个"综合输出最终方案"的指令会怂恿弱模型在席位
# 没集齐时就提前收口(实测:3/5 交付就臆称"三方已发言"草草收口)。此时只给摘要,让总控
# 倾向于继续把 ⬜ 席位派活集齐。
# 第 2 阶段(仅「综合轮 且 所有席位都已交付」):再**全文**取各已交付席位的交付,拼综合块。
# 还有 ⬜ 未交付席位时不注入 —— 否则"综合输出最终方案"的指令会怂恿弱模型在席位没集齐时
# 就提前收口(实测:3/5 交付就臆称"三方已发言"草草收口)。全文读只在这一轮才付出。
if synthesis_mode and not undelivered:
full_deliverables_block = _build_full_deliverables_block(seats_meta_list, delivery)
try:
full_delivery = await _collect_delivery_status(
client, base, headers,
{sid: agent_threads[sid] for sid in seat_ids if delivery.get(sid, (False, ""))[0]},
max_chars=0,
)
full_deliverables_block = _build_full_deliverables_block(seats_meta_list, full_delivery)
except Exception:
logger.warning("[leader-roster] 综合轮取全文交付失败,退化为仅摘要", exc_info=True)
logger.info(
"[leader-roster] %s 注入席位交付状态:%s synthesis_mode=%s undelivered=%d full_deliverables=%s",
agent_name,
@@ -20,7 +20,7 @@ import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from langgraph.checkpoint.base import empty_checkpoint
from langgraph.checkpoint.base import empty_checkpoint, uuid6
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
@@ -1009,6 +1009,138 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re
)
# ---------------------------------------------------------------------------
# Lightweight message append / last-AI read
#
# 圆桌广播与「席位交付状态」核对原本都走 GET /state + POST /state,要把动辄几万字的整份
# thread 状态经 loopback 传一两遍 —— 实测让总控每轮 payload_built 飙到 ~11s、广播也很慢。
# 下面两个轻量接口把重活留在服务端,loopback 只传一条小消息 / 截断后的摘要:
# • POST /{id}/messages/append —— 只传 1 条消息,服务端读 checkpoint+追加+写回;
# • GET /{id}/last-ai-message —— 只回最后一条 AI 交付(可 max_chars 截断),核对交付状态用。
# 注意:Postgres checkpoint 的 aput/aget 本身仍是 O(state) 的(快照模型),这里省掉的是
# 「整份 state 的 JSON 序列化 + loopback 往返」,这是可避免的那部分开销。
# ---------------------------------------------------------------------------
class AppendMessageRequest(BaseModel):
"""Request body for the lightweight single-message append."""
content: str = Field(..., description="Message text to append")
type: str = Field(default="human", description="Message role: human/ai/system")
def _flatten_msg_content(content: Any) -> str:
"""把 message.content(str / 结构化 block 列表)拍平成纯文本。"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
parts.append(str(item.get("text", "")))
return "".join(parts)
return str(content) if content else ""
def _extract_last_ai_text(messages: list[Any]) -> str:
"""返回最后一条 AI 消息的纯文本;没有则空串。兼容 message 对象与序列化 dict 两种存储形态。"""
for msg in reversed(messages or []):
if isinstance(msg, dict):
mtype = str(msg.get("type") or "").lower()
content = msg.get("content", "")
else:
mtype = str(getattr(msg, "type", "") or "").lower()
content = getattr(msg, "content", "")
if mtype in ("ai", "aimessage", "aimessagechunk"):
text = _flatten_msg_content(content)
if text.strip():
return text
return ""
async def _append_message_to_checkpoint(checkpointer: Any, thread_id: str, content: str, type_: str) -> int | None:
"""把一条消息追加进 thread 最新 checkpoint 的 ``messages`` 通道,返回新消息总数。
``None`` 表示线程不存在(无 checkpoint)。纯粹围绕 checkpointer 的 aget/aput,便于单测。
语义与 ``update_thread_state`` 一致:仅替换 ``messages`` 通道为追加后的列表,其它通道原样保留。
"""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(config)
if ckpt_tuple is None:
return None
checkpoint: dict[str, Any] = dict(getattr(ckpt_tuple, "checkpoint", {}) or {})
metadata: dict[str, Any] = dict(getattr(ckpt_tuple, "metadata", {}) or {})
channel_values: dict[str, Any] = dict(checkpoint.get("channel_values", {}))
messages = list(channel_values.get("messages") or [])
messages.append({"type": type_, "content": content})
channel_values["messages"] = messages
checkpoint["channel_values"] = channel_values
# 生成**新的** checkpoint 快照 id + ts —— 每次追加都落成一个新快照(uuid6 时间可排序,新 id
# 天然成为最新),而不是复用旧 id 原地覆盖(后端语义不一致,会让连续追加互相丢失)。
checkpoint["id"] = str(uuid6(clock_seq=-2))
checkpoint["ts"] = now_iso()
# **关键**:checkpointer 用「版本化 blob」存 channel —— 必须 bump ``messages`` 的版本并把它
# 放进 aput 的 new_versions,aput 才会真正写入该 channel 的新内容、aget 才能读回。否则(像原
# GET/POST /state 那样只改 channel_values、new_versions 传 {})对没有现成 messages 版本的线程
# 不生效。
versions = dict(checkpoint.get("channel_versions", {}))
new_ver = checkpointer.get_next_version(versions.get("messages"), None)
versions["messages"] = new_ver
checkpoint["channel_versions"] = versions
metadata["updated_at"] = now_iso()
await checkpointer.aput(config, checkpoint, metadata, {"messages": new_ver})
return len(messages)
@router.post("/{thread_id}/messages/append", response_model=None)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def append_thread_message(thread_id: str, body: AppendMessageRequest, request: Request) -> dict[str, Any]:
"""轻量追加一条消息到 thread checkpoint —— 调用方只传一条消息,不必 GET+POST 整份 state。
语义与 ``update_thread_state`` 的 messages 写入一致:直接追加到 ``messages`` 通道,下次图
运行时 ``add_messages`` 做规范化/按 id 去重。失败抛 5xx 由调用方按 best-effort 处理。
"""
checkpointer = get_checkpointer(request)
try:
count = await _append_message_to_checkpoint(checkpointer, thread_id, body.content, body.type)
except Exception:
logger.exception("Failed to append message on thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to append thread message")
if count is None:
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
return {"ok": True, "message_count": count}
@router.get("/{thread_id}/last-ai-message", response_model=None)
@require_permission("threads", "read", owner_check=True)
async def get_thread_last_ai_message(thread_id: str, request: Request, max_chars: int = 0) -> dict[str, Any]:
"""轻量读取 thread 最后一条 AI 消息(可截断),供圆桌核对席位交付状态。
返回 ``{delivered, content, length}``:``delivered`` = 是否存在非空 AI 交付;``max_chars>0``
时 ``content`` 截断到该长度(派活轮取摘要),``<=0`` 取全文(综合轮)。``length`` 是全文长度。
线程不存在时返回 ``delivered=False``(而非 404),方便调用方把"还没建好/没交付"统一当未交付。
"""
checkpointer = get_checkpointer(request)
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
ckpt_tuple = await checkpointer.aget_tuple(config)
except Exception:
logger.exception("Failed to read last-ai-message on thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to get thread state")
if ckpt_tuple is None:
return {"delivered": False, "content": "", "length": 0}
channel_values = (getattr(ckpt_tuple, "checkpoint", {}) or {}).get("channel_values", {}) or {}
messages = channel_values.get("messages") or []
text = _extract_last_ai_text(messages if isinstance(messages, list) else [])
full_len = len(text)
if max_chars and max_chars > 0:
text = text[:max_chars]
return {"delivered": full_len > 0, "content": text, "length": full_len}
@router.delete("/{thread_id}/messages/{message_id}")
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def delete_thread_message(thread_id: str, message_id: str, request: Request) -> dict[str, Any]: