feat:完成重构和测试

This commit is contained in:
2026-10-02 08:47:24 -07:00
parent 5b1e6c5654
commit 62a5b2a8f4
15 changed files with 138 additions and 43 deletions
+4
View File
@@ -16,6 +16,10 @@ INTENT_QA_MIN_PROBABILITY=0.5
# screen agent 单次运行的模型调用上限,包括读取 skill、查询物料、提交修正和最终回复 # screen agent 单次运行的模型调用上限,包括读取 skill、查询物料、提交修正和最终回复
MAX_AGENT_STEPS=12 MAX_AGENT_STEPS=12
# 会话历史:会话记录完整保存,每次请求最多读取最近 20 轮,再按 token 预算从最近一轮往前保留完整的轮次
CONVERSATION_HISTORY_TURNS=20
HISTORY_MAX_TOKENS=4096
# MongoDB 会话记忆与检查点持久化,不配置则使用进程内存,重启后丢失 # MongoDB 会话记忆与检查点持久化,不配置则使用进程内存,重启后丢失
# MONGODB_URI=mongodb://127.0.0.1:27017 # MONGODB_URI=mongodb://127.0.0.1:27017
MONGODB_DB=screen_agent MONGODB_DB=screen_agent
+10 -2
View File
@@ -39,13 +39,21 @@ def merge_config(defaults: dict, overrides: dict) -> dict:
def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction: def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction:
"""按 node.type 找物料补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data""" """按 node.type 找物料补全新增组件,模型显式写出的字段优先
sample_data 模型写了就以模型为准(只有它知道是否换成了自己的数据),没写时按物料设置
"""
material = find_material(catalog, action.node.type) material = find_material(catalog, action.node.type)
if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"):
raise ValueError(DATA_BINDING_ERROR) raise ValueError(DATA_BINDING_ERROR)
overrides = action.node.model_dump(mode="json", exclude_unset=True) overrides = action.node.model_dump(mode="json", exclude_unset=True)
node = ScreenNode.model_validate(merge_config(material.template(), overrides)) node = ScreenNode.model_validate(merge_config(material.template(), overrides))
return action.model_copy(update={"node": node, "sample_data": material.sample_data}) sample_data = (
action.sample_data
if "sample_data" in action.model_fields_set
else material.sample_data
)
return action.model_copy(update={"node": node, "sample_data": sample_data})
def run_batch( def run_batch(
+26 -9
View File
@@ -10,7 +10,7 @@ from typing import Literal, Self
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, wrap_model_call from langchain.agents.middleware import ModelCallLimitMiddleware, wrap_model_call
from langchain.agents.structured_output import ToolStrategy from langchain.agents.structured_output import ToolStrategy
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage, HumanMessage
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
@@ -62,21 +62,36 @@ class AgentReply(BaseModel):
return self return self
# 偶发的空回复最多重新调用几次 # 模型没有调用工具时最多纠正几次
EMPTY_REPLY_RETRIES = 2 NO_TOOL_CALL_RETRIES = 2
NO_TOOL_CALL_HINT = (
"每一轮都要调用工具。要结束时调用 AgentReply,给用户的话写在 message 里"
)
@wrap_model_call @wrap_model_call
async def retry_empty_reply(request, handler): async def require_tool_call(request, handler):
"""每一轮都必须调用工具(最后一轮是 AgentReply);模型偶尔返回既没有文本也没有工具调用的空回复, """ToolStrategy 要求每一轮都调用工具,结束时调用 AgentReply;模型偶尔只回文本(想直接回复用户)或空回复
这是调用失败而不是模型的决定,重新调用即可"""
原样重试往往得到同样的结果,或者模型转而重复提交 apply_actions 直到调用次数用尽,
所以把它的回复和纠正说明加在下一次调用里;只用于这一次调用,不写进 agent 的消息
"""
response = await handler(request) response = await handler(request)
for _ in range(EMPTY_REPLY_RETRIES): for _ in range(NO_TOOL_CALL_RETRIES):
message = response.result[0] if response.result else None message = response.result[0] if response.result else None
if not isinstance(message, AIMessage) or message.tool_calls: if not isinstance(message, AIMessage) or message.tool_calls:
break break
logger.warning( logger.warning(
f"模型返回了没有工具调用的回复,重新调用: {message.response_metadata}" f"模型没有调用工具,纠正后重新调用: {str(message.content)[:200]}"
)
# 空回复不放回去,部分接口不接受内容为空的助手消息
replied = [message] if message.content else []
request = request.override(
messages=[
*request.messages,
*replied,
HumanMessage(content=NO_TOOL_CALL_HINT),
]
) )
response = await handler(request) response = await handler(request)
return response return response
@@ -93,12 +108,13 @@ async def get_screen_agent():
model=get_llm(), model=get_llm(),
tools=[get_nodes, apply_actions, *await get_mcp_tools()], tools=[get_nodes, apply_actions, *await get_mcp_tools()],
system_prompt=SYSTEM_PROMPT, system_prompt=SYSTEM_PROMPT,
# 用工具调用给出结论:试过原生结构化输出(ProviderStrategy),歧义时模型更常直接执行而不追问,评测多删了组件
response_format=ToolStrategy(AgentReply), response_format=ToolStrategy(AgentReply),
state_schema=ScreenAgentState, state_schema=ScreenAgentState,
context_schema=ScreenAgentContext, context_schema=ScreenAgentContext,
middleware=[ middleware=[
*skill_middleware(), *skill_middleware(),
retry_empty_reply, require_tool_call,
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理 # 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
ModelCallLimitMiddleware( ModelCallLimitMiddleware(
run_limit=settings.max_agent_steps, exit_behavior="end" run_limit=settings.max_agent_steps, exit_behavior="end"
@@ -108,4 +124,5 @@ async def get_screen_agent():
checkpointer=False, checkpointer=False,
name="screen_agent", name="screen_agent",
) )
return _agent return _agent
+7 -1
View File
@@ -10,6 +10,7 @@ from langchain.agents import AgentState
from langchain.tools import ToolRuntime, tool from langchain.tools import ToolRuntime, tool
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
from langgraph.types import Command from langgraph.types import Command
from pydantic import Field
from app.actions.batch import BatchError, run_batch from app.actions.batch import BatchError, run_batch
from app.schemas.actions import Action, AddAction from app.schemas.actions import Action, AddAction
@@ -63,7 +64,12 @@ def _result_text(actions: list[Action]) -> str:
@tool @tool
def apply_actions( def apply_actions(
actions: list[Action], runtime: ToolRuntime[ScreenAgentContext] actions: Annotated[
list[Action],
# 空的一批会清掉之前成功的结果;参数校验失败不改状态,之前的结果保留
Field(min_length=1, description="至少一个动作;不需要修改时不要调用,直接回复"),
],
runtime: ToolRuntime[ScreenAgentContext],
) -> Command: ) -> Command:
"""提交本次要对大屏做的全部修改 """提交本次要对大屏做的全部修改
+8 -2
View File
@@ -58,9 +58,13 @@ class Settings:
conversation_empty_ttl: int = field( conversation_empty_ttl: int = field(
default_factory=lambda: int(os.getenv("CONVERSATION_EMPTY_TTL", "7200")) default_factory=lambda: int(os.getenv("CONVERSATION_EMPTY_TTL", "7200"))
) )
# 每次请求带入模型的最近对话轮数 # 每次请求从会话记录读取的最近轮数上限,实际带入多少由 history_max_tokens 决定
conversation_history_turns: int = field( conversation_history_turns: int = field(
default_factory=lambda: int(os.getenv("CONVERSATION_HISTORY_TURNS", "6")) default_factory=lambda: int(os.getenv("CONVERSATION_HISTORY_TURNS", "20"))
)
# 每次请求带入的会话历史 token 预算(近似计数),从最近一轮往前保留完整的轮次
history_max_tokens: int = field(
default_factory=lambda: int(os.getenv("HISTORY_MAX_TOKENS", "4096"))
) )
# MongoDB 连接串,未配置时会话记录和检查点回退为进程内存实现 # MongoDB 连接串,未配置时会话记录和检查点回退为进程内存实现
@@ -107,6 +111,8 @@ class Settings:
raise ValueError("CONVERSATION_EMPTY_TTL 必须是正整数") raise ValueError("CONVERSATION_EMPTY_TTL 必须是正整数")
if self.conversation_history_turns < 0: if self.conversation_history_turns < 0:
raise ValueError("CONVERSATION_HISTORY_TURNS 必须是非负整数") raise ValueError("CONVERSATION_HISTORY_TURNS 必须是非负整数")
if self.history_max_tokens < 1:
raise ValueError("HISTORY_MAX_TOKENS 必须是正整数")
if self.max_agent_steps < 1: if self.max_agent_steps < 1:
raise ValueError("MAX_AGENT_STEPS 必须是正整数") raise ValueError("MAX_AGENT_STEPS 必须是正整数")
if self.mcp_tool_timeout <= 0: if self.mcp_tool_timeout <= 0:
+39
View File
@@ -0,0 +1,39 @@
"""会话历史:数据库保存完整对话,每次请求按 token 预算只带最近的完整轮次
裁剪在服务层组装请求时做一次,意图判断、问答和 screen agent 看到的是同一段历史
"""
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
trim_messages,
)
from langchain_core.messages.utils import count_tokens_approximately
from app.schemas.base import JsonObject
def history_messages(history: list[JsonObject]) -> list[BaseMessage]:
"""每轮转成一条用户消息和一条助手消息,按时间从早到晚排列"""
messages: list[BaseMessage] = []
for turn in history:
messages.append(HumanMessage(content=turn["user"]))
messages.append(AIMessage(content=turn["assistant"]))
return messages
def trim_history(history: list[JsonObject], max_tokens: int) -> list[JsonObject]:
"""从最近一轮往前保留,总量不超过 max_tokens;只保留完整的轮次,不从一段回复中间截断
单独一轮就超过预算时这一轮也不保留
"""
kept = trim_messages(
history_messages(history),
max_tokens=max_tokens,
token_counter=count_tokens_approximately,
strategy="last",
# 从用户消息开始,助手回复放不下时连同它的用户消息一起去掉
start_on="human",
)
return history[len(history) - len(kept) // 2 :]
+3 -1
View File
@@ -14,8 +14,10 @@ class IntentNode(BaseNode):
async def process(self, state: QueryState) -> QueryState: async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy() updated_state: QueryState = state.copy()
context = state["input_context"]
request = DecisionRequest( request = DecisionRequest(
state=request_summary(state["input_context"]), # Jev 只接收 JSON,会话历史作为 history 字段和本次请求放在一起
state={"history": context.history, **request_summary(context)},
decisions={"intent": build_intent_question()}, decisions={"intent": build_intent_question()},
) )
response = await get_decision_engine().run(request) response = await get_decision_engine().run(request)
+7 -1
View File
@@ -6,6 +6,7 @@ from langgraph.config import get_stream_writer
from app.clients.llm import get_llm from app.clients.llm import get_llm
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.history import history_messages
from app.processor.state import QueryState from app.processor.state import QueryState
from app.prompt.qa import QA_SYSTEM_PROMPT from app.prompt.qa import QA_SYSTEM_PROMPT
@@ -24,11 +25,16 @@ class QANode(BaseNode):
parser = StrOutputParser() parser = StrOutputParser()
parts: list[str] = [] parts: list[str] = []
context = state["input_context"]
async for chunk in get_llm().astream( async for chunk in get_llm().astream(
[ [
SystemMessage(content=QA_SYSTEM_PROMPT), SystemMessage(content=QA_SYSTEM_PROMPT),
# 会话历史作为此前的对话消息,本次请求是最后一条用户消息
*history_messages(context.history),
HumanMessage( HumanMessage(
content=state["input_context"].model_dump_json(exclude_unset=True) content=context.model_dump_json(
exclude_unset=True, exclude={"history"}
)
), ),
] ]
): ):
+4 -1
View File
@@ -10,6 +10,7 @@ from langchain_core.messages import HumanMessage
from app.agent.screen_agent import AgentReply, get_screen_agent from app.agent.screen_agent import AgentReply, get_screen_agent
from app.agent.tools import ScreenAgentContext from app.agent.tools import ScreenAgentContext
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.history import history_messages
from app.processor.state import QueryState from app.processor.state import QueryState
from app.processor.summary import request_summary from app.processor.summary import request_summary
from app.resources.component import get_catalog_cache from app.resources.component import get_catalog_cache
@@ -55,10 +56,12 @@ class ScreenAgentNode(BaseNode):
agent = await get_screen_agent() agent = await get_screen_agent()
result = await agent.ainvoke( result = await agent.ainvoke(
{ {
# 会话历史作为此前的对话消息,本次请求是最后一条用户消息
"messages": [ "messages": [
*history_messages(context.history),
HumanMessage( HumanMessage(
content=json.dumps(request_summary(context), ensure_ascii=False) content=json.dumps(request_summary(context), ensure_ascii=False)
) ),
] ]
}, },
context=ScreenAgentContext(screen=context.screen, catalog=catalog), context=ScreenAgentContext(screen=context.screen, catalog=catalog),
+5 -3
View File
@@ -1,11 +1,14 @@
"""请求摘要:意图判断和 screen agent 共用,只给组件的识别信息和布局,完整配置按需读取""" """请求摘要:意图判断和 screen agent 共用,只给组件的识别信息和布局,完整配置按需读取
只描述本次请求;会话历史由各使用方另行传入,见 history.py
"""
from app.schemas.base import JsonObject from app.schemas.base import JsonObject
from app.schemas.input_context import InputContext from app.schemas.input_context import InputContext
def request_summary(context: InputContext) -> JsonObject: def request_summary(context: InputContext) -> JsonObject:
"""用户输入、会话历史、澄清记录、编辑上下文、附件和大屏摘要""" """用户输入、澄清记录、编辑上下文、附件和大屏摘要"""
nodes: list[JsonObject] = [] nodes: list[JsonObject] = []
for node in context.screen.nodes: for node in context.screen.nodes:
# 图表标题帮助判断用户指的是哪个组件;没有 option 或标题格式不符时按无标题处理 # 图表标题帮助判断用户指的是哪个组件;没有 option 或标题格式不符时按无标题处理
@@ -28,7 +31,6 @@ def request_summary(context: InputContext) -> JsonObject:
return { return {
"prompt": context.prompt, "prompt": context.prompt,
"history": context.history,
"clarification_history": context.clarification_history, "clarification_history": context.clarification_history,
"context": context.context.model_dump(mode="json", exclude_unset=True), "context": context.context.model_dump(mode="json", exclude_unset=True),
"screen_summary": { "screen_summary": {
+2 -2
View File
@@ -11,8 +11,8 @@ QA_SYSTEM_PROMPT = f"""## 任务
可以简短提示还能继续帮忙,不列举能力清单 可以简短提示还能继续帮忙,不列举能力清单
## 回答依据 ## 回答依据
结合当前问题、大屏快照、history(此前各轮的用户输入与助手摘要)和 clarification_history,以用户后续明确补充或纠正为准 结合当前问题、大屏快照、此前的对话和 clarification_history,以用户后续明确补充或纠正为准
理解“刚才那个”“再大一点”等指代时参考 history,但当前大屏快照才是最新事实 理解“刚才那个”“再大一点”等指代时参考此前的对话,但当前大屏快照才是最新事实
仅在与请求相关时使用大屏快照;一般交流无需强行关联大屏或要求用户补充大屏信息 仅在与请求相关时使用大屏快照;一般交流无需强行关联大屏或要求用户补充大屏信息
区分快照事实与待验证的推测,证据不足时说明缺少的信息,不编造故障原因 区分快照事实与待验证的推测,证据不足时说明缺少的信息,不编造故障原因
+2 -1
View File
@@ -19,5 +19,6 @@ class AddAction(ActionModel):
) )
sample_data: bool = Field( sample_data: bool = Field(
default=False, default=False,
description="由后端按物料设置,不用填写;true 表示组件使用物料示例数据,不是真实业务数据", description="true 表示组件显示的不是真实业务数据(物料示例数据,或自己编写的数据)。不写时按物料设置;"
"只有展示的数据全部来自大屏上已有的组件时写 false,例如更换类型时迁移了原组件的数据",
) )
+1 -1
View File
@@ -14,7 +14,7 @@ class Turn(BaseModel):
message_id: str message_id: str
prompt: str # 用户原始输入 prompt: str # 用户原始输入
outcome: Literal["completed", "waiting_for_input", "failed", "cancelled"] outcome: Literal["completed", "waiting_for_input", "failed", "cancelled"]
summary: str # 助手侧摘要,只描述后端生成了什么,不代表前端已执行 summary: str # 助手一侧的完整回复(回答、追问或失败说明),不截断;不代表前端已执行
created_at: datetime created_at: datetime
+1 -1
View File
@@ -10,5 +10,5 @@ class InputContext(DesignRequest):
"""当前复用请求协议,后续可添加解析结果而不改变前端传参""" """当前复用请求协议,后续可添加解析结果而不改变前端传参"""
clarification_history: list[JsonObject] = Field(default_factory=list) clarification_history: list[JsonObject] = Field(default_factory=list)
# 同一会话此前各轮的用户输入与助手摘要,由服务端按 conversation_id 写入,按时间从早到晚排列 # 同一会话此前各轮的用户输入与助手回复,由服务端按 conversation_id 读取并按 token 预算裁剪,按时间从早到晚排列
history: list[JsonObject] = Field(default_factory=list) history: list[JsonObject] = Field(default_factory=list)
+18 -17
View File
@@ -10,19 +10,13 @@ from loguru import logger
from app.config import settings from app.config import settings
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.observability.tracing import astream_screen from app.observability.tracing import astream_screen
from app.processor.history import trim_history
from app.schemas.agent import AgentResult, ResumeRequest from app.schemas.agent import AgentResult, ResumeRequest
from app.schemas.conversation import Turn from app.schemas.conversation import Turn
from app.schemas.request import DesignRequest from app.schemas.request import DesignRequest
from app.services.conversation import ConversationStore 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: class AgentService:
def __init__(self, graph, store: ConversationStore): def __init__(self, graph, store: ConversationStore):
self.graph = graph self.graph = graph
@@ -51,11 +45,18 @@ class AgentService:
raise raise
payload = request.model_dump(mode="json") payload = request.model_dump(mode="json")
# 历史只信任服务端记录,覆盖前端可能携带的同名扩展字段 # 历史只信任服务端记录,覆盖前端可能携带的同名扩展字段;记录是完整的,这里按 token 预算裁剪
payload["history"] = [ payload["history"] = trim_history(
{"user": turn.prompt, "assistant": turn.summary, "outcome": turn.outcome} [
{
"user": turn.prompt,
"assistant": turn.summary,
"outcome": turn.outcome,
}
for turn in turns for turn in turns
] ],
settings.history_max_tokens,
)
return ( return (
thread_id, thread_id,
@@ -144,17 +145,17 @@ class AgentService:
self._conversation_of[thread_id] = conversation_id self._conversation_of[thread_id] = conversation_id
@staticmethod @staticmethod
def _summarize(state, result: AgentResult) -> str: def _summarize(result: AgentResult) -> str:
"""用一句话描述本轮结果,只陈述后端生成了什么,不代表前端已执行""" """本轮助手一侧的完整文本,只陈述后端生成了什么,不代表前端已执行;不截断,带入请求时再按预算裁剪"""
if result.status == "waiting_for_input": if result.status == "waiting_for_input":
question = result.clarification.question if result.clarification else "" question = result.clarification.question if result.clarification else ""
return _clip(f"向用户提问:{question}") return f"向用户提问:{question}"
if result.status == "cancelled": if result.status == "cancelled":
return "用户取消了本次补充" return "用户取消了本次补充"
if result.status == "failed": if result.status == "failed":
return _clip(f"处理失败:{result.answer or '未知原因'}") return f"处理失败:{result.answer or '未知原因'}"
if result.answer: if result.answer:
return _clip(result.answer) return result.answer
return f"已生成 {len(result.actions)} 个画布动作" return f"已生成 {len(result.actions)} 个画布动作"
async def _save_turn(self, state, result: AgentResult) -> None: async def _save_turn(self, state, result: AgentResult) -> None:
@@ -167,7 +168,7 @@ class AgentService:
message_id=result.message_id, message_id=result.message_id,
prompt=state["request"]["prompt"], prompt=state["request"]["prompt"],
outcome=result.status, outcome=result.status,
summary=self._summarize(state, result), summary=self._summarize(result),
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
) )
) )