feat:优化流式输出,增加临时多轮对话记录
This commit is contained in:
+3
-1
@@ -12,7 +12,9 @@ router = APIRouter(prefix="/agent", tags=["Agent"])
|
||||
@router.post("/run", response_class=AgentEventResponse, status_code=200)
|
||||
async def stream_agent(body: DesignRequest, request: Request):
|
||||
service = request.app.state.agent_service
|
||||
return AgentEventResponse(service, service.stream_run(body), request)
|
||||
# 在发送流响应头之前完成会话校验并占用任务
|
||||
execution = await service.prepare_run(body)
|
||||
return AgentEventResponse(service, execution, request)
|
||||
|
||||
|
||||
@router.post("/resume", response_class=AgentEventResponse, status_code=200)
|
||||
|
||||
@@ -50,7 +50,20 @@ class Settings:
|
||||
default_factory=lambda: int(os.getenv("MAX_ACTION_RETRIES", "2"))
|
||||
)
|
||||
|
||||
# 没有任何对话的空会话保留时长(秒),超时后视为不存在
|
||||
conversation_empty_ttl: int = field(
|
||||
default_factory=lambda: int(os.getenv("CONVERSATION_EMPTY_TTL", "7200"))
|
||||
)
|
||||
# 每次请求带入模型的最近对话轮数
|
||||
conversation_history_turns: int = field(
|
||||
default_factory=lambda: int(os.getenv("CONVERSATION_HISTORY_TURNS", "6"))
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.conversation_empty_ttl <= 0:
|
||||
raise ValueError("CONVERSATION_EMPTY_TTL 必须是正整数")
|
||||
if self.conversation_history_turns < 0:
|
||||
raise ValueError("CONVERSATION_HISTORY_TURNS 必须是非负整数")
|
||||
if type(self.max_action_retries) is not int or self.max_action_retries < 0:
|
||||
raise ValueError("MAX_ACTION_RETRIES 必须是非负整数")
|
||||
if not 0 <= self.intent_min_confidence <= 1:
|
||||
|
||||
+8
-1
@@ -7,6 +7,7 @@ from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
|
||||
from app.api.agent import router
|
||||
from app.api.conversations import router as conversation_router
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
@@ -19,7 +20,12 @@ async def lifespan(application: FastAPI):
|
||||
from app.processor.graph import build_graph
|
||||
from app.services.agent import AgentService
|
||||
|
||||
application.state.agent_service = AgentService(build_graph())
|
||||
from app.services.conversation import InMemoryConversationStore
|
||||
|
||||
# TODO 接入 MongoDB 后替换为持久化实现,并同步替换图的 checkpointer
|
||||
store = InMemoryConversationStore()
|
||||
application.state.conversation_store = store
|
||||
application.state.agent_service = AgentService(build_graph(), store)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -38,6 +44,7 @@ def create_app() -> FastAPI:
|
||||
# 统一处理请求校验、HTTP 错误和未捕获异常
|
||||
register_exception_handlers(application)
|
||||
application.include_router(router)
|
||||
application.include_router(conversation_router)
|
||||
return application
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class IntentNode(BaseNode):
|
||||
# 汇总用户需求、澄清历史、交互上下文和大屏摘要,作为意图判断的输入
|
||||
return {
|
||||
"prompt": context.prompt,
|
||||
"history": context.history,
|
||||
"clarification_history": context.clarification_history,
|
||||
"context": context.context.model_dump(mode="json", exclude_unset=True),
|
||||
"screen_summary": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
from app.clients.llm import get_llm
|
||||
from app.processor.base import BaseNode
|
||||
@@ -18,8 +19,16 @@ class QANode(BaseNode):
|
||||
|
||||
# 获取普通对话模型,问答只需文本回答,不绑定结构化输出协议
|
||||
llm_client = get_llm()
|
||||
# 异步调用模型,结合用户问题、当前大屏和澄清记录生成回答
|
||||
llm_res = await llm_client.ainvoke(
|
||||
try:
|
||||
# 图执行上下文中通过 custom 流逐段推送回答,独立调用节点时没有写入器
|
||||
writer = get_stream_writer()
|
||||
except RuntimeError:
|
||||
writer = None
|
||||
|
||||
parser = StrOutputParser()
|
||||
parts: list[str] = []
|
||||
# 异步流式调用模型,结合用户问题、当前大屏和澄清记录生成回答
|
||||
async for chunk in llm_client.astream(
|
||||
[
|
||||
# 系统消息提供稳定的问答规则和回答边界
|
||||
SystemMessage(content=QA_SYSTEM_PROMPT),
|
||||
@@ -28,10 +37,23 @@ class QANode(BaseNode):
|
||||
content=state["input_context"].model_dump_json(exclude_unset=True)
|
||||
),
|
||||
]
|
||||
):
|
||||
# 从每个片段提取文本,兼容文本内容块,工具调用等无文本片段会得到空串
|
||||
delta = parser.invoke(chunk)
|
||||
if not delta:
|
||||
continue
|
||||
parts.append(delta)
|
||||
if writer is not None:
|
||||
writer(
|
||||
{
|
||||
"type": "answer_delta",
|
||||
"message_id": state.get("message_id"),
|
||||
"delta": delta,
|
||||
}
|
||||
)
|
||||
|
||||
# 从模型响应中提取文本,兼容文本内容块,并去除回答首尾的空白
|
||||
answer = StrOutputParser().invoke(llm_res).strip()
|
||||
# 拼接完整回答,并去除首尾的空白
|
||||
answer = "".join(parts).strip()
|
||||
# 如果没有有效文本,抛出异常,避免将空回答标记为处理完成
|
||||
if not answer:
|
||||
raise ValueError("问答模型未返回文本回答")
|
||||
|
||||
@@ -9,7 +9,7 @@ def build_intent_question() -> ChoiceDecision:
|
||||
instructions=(
|
||||
"Classify the user's primary intent. "
|
||||
"Classify the latest user answer in `clarification_history`, or `prompt` when there is no history. "
|
||||
"Use earlier turns and the selected option to resolve references; the latest answer overrides earlier conflicting intent. "
|
||||
"Use earlier turns, `history` (previous turns of this conversation) and the selected option to resolve references; the latest answer overrides earlier conflicting intent. "
|
||||
"Once the requested action or response is clear, earlier ambiguity does not require another confirmation. "
|
||||
"Choose modify_screen only when the user explicitly requests an actual edit. "
|
||||
"Choose qa for clear requests for a textual answer or general conversation, including information, explanation, diagnosis, advice, and assistant identity or capabilities. "
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ QA_SYSTEM_PROMPT = """## 任务
|
||||
也可回答助手身份、能力等一般交流请求;介绍自己时以大屏助手的职责和已知能力为依据,不编造模型身份或未提供的能力
|
||||
|
||||
## 回答依据
|
||||
结合当前问题、大屏快照和 clarification_history,以用户后续明确补充或纠正为准
|
||||
结合当前问题、大屏快照、history(此前各轮的用户输入与助手摘要)和 clarification_history,以用户后续明确补充或纠正为准
|
||||
理解“刚才那个”“再大一点”等指代时参考 history,但当前大屏快照才是最新事实
|
||||
仅在与请求相关时使用大屏快照;一般交流无需强行关联大屏或要求用户补充大屏信息
|
||||
区分快照事实与待验证的推测,证据不足时说明缺少的信息,不编造故障原因
|
||||
|
||||
|
||||
@@ -49,10 +49,14 @@ REQUIREMENT_INPUT_TEMPLATE = """历史会话:
|
||||
|
||||
|
||||
def build_requirement_input(
|
||||
context: InputContext, intent: Intent, *, history_text: str = "未提供"
|
||||
context: InputContext, intent: Intent, *, history_text: str | None = None
|
||||
) -> str:
|
||||
"""整理本次模型输入,历史会话由调用方加载后传入"""
|
||||
"""整理本次模型输入,历史会话默认取输入上下文中由服务端写入的记录"""
|
||||
data = context.model_dump(mode="json", exclude_unset=True)
|
||||
# 会话历史
|
||||
history = data.pop("history", [])
|
||||
if history_text is None:
|
||||
history_text = json.dumps(history, ensure_ascii=False) if history else "未提供"
|
||||
content = REQUIREMENT_INPUT_TEMPLATE.format(
|
||||
history_text=history_text,
|
||||
clarification_text=json.dumps(
|
||||
|
||||
@@ -10,3 +10,5 @@ class InputContext(DesignRequest):
|
||||
"""当前复用请求协议,后续可添加解析结果而不改变前端传参"""
|
||||
|
||||
clarification_history: list[JsonObject] = Field(default_factory=list)
|
||||
# 同一会话此前各轮的用户输入与助手摘要,由服务端按 conversation_id 写入,按时间从早到晚排列
|
||||
history: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
+90
-12
@@ -1,29 +1,61 @@
|
||||
"""异步执行与恢复大屏任务,当前使用单进程内存检查点"""
|
||||
|
||||
from contextlib import aclosing
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.types import Command
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
from app.core.exceptions import BizException
|
||||
from app.observability.tracing import astream_screen
|
||||
from app.schemas.agent import AgentResult, ResumeRequest
|
||||
from app.schemas.conversation import Turn
|
||||
from app.schemas.request import DesignRequest
|
||||
from app.services.conversation import ConversationStore
|
||||
|
||||
|
||||
SUMMARY_MAX_CHARS = 500
|
||||
|
||||
|
||||
def _clip(text: str) -> str:
|
||||
return text if len(text) <= SUMMARY_MAX_CHARS else text[:SUMMARY_MAX_CHARS] + "…"
|
||||
|
||||
|
||||
class AgentService:
|
||||
def __init__(self, graph):
|
||||
def __init__(self, graph, store: ConversationStore):
|
||||
self.graph = graph
|
||||
self.store = store
|
||||
# 同一时刻,一个任务和一个会话都只允许一个执行,避免历史顺序错乱
|
||||
self._active: set[str] = set()
|
||||
self._conversation_of: dict[str, str] = {}
|
||||
|
||||
async def prepare_run(self, request: DesignRequest):
|
||||
# 会话 ID 必须由后端签发,不再为缺失的 ID 兜底生成
|
||||
conversation_id = request.context.conversation_id
|
||||
if conversation_id is None:
|
||||
raise BizException(422, "请先创建会话并传入 conversation_id")
|
||||
if not await self.store.exists(conversation_id):
|
||||
raise BizException(404, "会话不存在或已过期")
|
||||
|
||||
def _prepare_run(self, request: DesignRequest):
|
||||
# 新请求始终独立建任务,不用 conversation_id 充当检查点 ID
|
||||
thread_id = str(uuid4())
|
||||
# 暂无会话创建接口,未传会话 ID 时先分配标识,不创建数据库记录
|
||||
conversation_id = request.context.conversation_id or str(uuid4())
|
||||
self._claim(thread_id, conversation_id)
|
||||
try:
|
||||
turns = await self.store.load_turns(
|
||||
conversation_id, settings.conversation_history_turns
|
||||
)
|
||||
except BaseException:
|
||||
self.release(thread_id)
|
||||
raise
|
||||
|
||||
payload = request.model_dump(mode="json")
|
||||
payload["context"]["conversation_id"] = conversation_id
|
||||
# 历史只信任服务端记录,覆盖前端可能携带的同名扩展字段
|
||||
payload["history"] = [
|
||||
{"user": turn.prompt, "assistant": turn.summary, "outcome": turn.outcome}
|
||||
for turn in turns
|
||||
]
|
||||
|
||||
return (
|
||||
thread_id,
|
||||
@@ -36,11 +68,6 @@ class AgentService:
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
def stream_run(self, request: DesignRequest):
|
||||
execution = self._prepare_run(request)
|
||||
self._claim(execution[0])
|
||||
return execution
|
||||
|
||||
async def prepare_resume(self, request: ResumeRequest):
|
||||
# 在发送流响应头之前完成校验并占用任务
|
||||
thread_id = str(request.thread_id)
|
||||
@@ -65,6 +92,7 @@ class AgentService:
|
||||
raise BizException(422, "请选择当前问题的有效选项")
|
||||
|
||||
conversation_id = snapshot.values["conversation_id"]
|
||||
self._claim_conversation(thread_id, conversation_id)
|
||||
# 恢复同一任务不能切换会话,补充编辑上下文时也保留原会话标识
|
||||
if reply.context is not None:
|
||||
if reply.context.conversation_id not in (None, conversation_id):
|
||||
@@ -92,11 +120,56 @@ class AgentService:
|
||||
|
||||
def release(self, thread_id):
|
||||
self._active.discard(thread_id)
|
||||
self._conversation_of.pop(thread_id, None)
|
||||
|
||||
def _claim(self, thread_id: str) -> None:
|
||||
def _claim(self, thread_id: str, conversation_id: str | None = None) -> None:
|
||||
if thread_id in self._active:
|
||||
raise BizException(409, "任务正在执行,请勿重复提交")
|
||||
self._active.add(thread_id)
|
||||
if conversation_id is not None:
|
||||
try:
|
||||
self._claim_conversation(thread_id, conversation_id)
|
||||
except BaseException:
|
||||
self.release(thread_id)
|
||||
raise
|
||||
|
||||
def _claim_conversation(self, thread_id: str, conversation_id: str) -> None:
|
||||
if conversation_id in self._conversation_of.values():
|
||||
raise BizException(409, "该会话有任务正在执行,请稍后再试")
|
||||
self._conversation_of[thread_id] = conversation_id
|
||||
|
||||
@staticmethod
|
||||
def _summarize(state, result: AgentResult) -> str:
|
||||
"""用一句话描述本轮结果,只陈述后端生成了什么,不代表前端已执行"""
|
||||
if result.status == "waiting_for_input":
|
||||
question = result.clarification.question if result.clarification else ""
|
||||
return _clip(f"向用户提问:{question}")
|
||||
if result.status == "cancelled":
|
||||
return "用户取消了本次补充"
|
||||
if result.status == "failed":
|
||||
return _clip(f"处理失败:{result.failure_reason or '未知原因'}")
|
||||
if result.answer:
|
||||
return _clip(result.answer)
|
||||
requirement = state.get("requirement")
|
||||
goal = requirement.goal if requirement is not None else "修改大屏"
|
||||
return _clip(f"{goal}(已生成 {len(result.actions)} 个画布动作)")
|
||||
|
||||
async def _save_turn(self, state, result: AgentResult) -> None:
|
||||
# 历史写入失败不影响本次结果交付,只记录日志
|
||||
try:
|
||||
await self.store.save_turn(
|
||||
Turn(
|
||||
conversation_id=result.conversation_id,
|
||||
thread_id=result.thread_id,
|
||||
message_id=result.message_id,
|
||||
prompt=state["request"]["prompt"],
|
||||
outcome=result.status,
|
||||
summary=self._summarize(state, result),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("保存对话轮次失败")
|
||||
|
||||
@staticmethod
|
||||
def _result(state, thread_id):
|
||||
@@ -129,6 +202,11 @@ class AgentService:
|
||||
state = data
|
||||
elif data.get("type") == "node_progress" and data.get("label"):
|
||||
yield "progress", data
|
||||
elif data.get("type") == "answer_delta":
|
||||
# 问答文本逐段推送,完整回答仍在 final 事件中返回
|
||||
yield "answer_delta", data
|
||||
if state is None:
|
||||
raise RuntimeError("图未返回状态")
|
||||
yield "final", self._result(state, thread_id).model_dump(mode="json")
|
||||
result = self._result(state, thread_id)
|
||||
await self._save_turn(state, result)
|
||||
yield "final", result.model_dump(mode="json")
|
||||
|
||||
Reference in New Issue
Block a user