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