feat:记忆&上下文管理

This commit is contained in:
2026-10-14 08:19:52 -07:00
parent 41b769adde
commit 58604e0f1c
15 changed files with 357 additions and 49 deletions
+2
View File
@@ -52,5 +52,7 @@ class InputNode(BaseNode):
updated_state["clarification"] = None
updated_state["answer"] = None
updated_state["suggestions"] = None
updated_state["changes"] = None
updated_state["queries"] = None
updated_state["status"] = "running"
return updated_state
+6 -2
View File
@@ -2,6 +2,7 @@
from app.clients.decision import get_decision_engine
from app.processor.base import BaseNode
from app.processor.history import intent_history
from app.processor.intent_policy import resolve_intent
from app.processor.state import QueryState
from app.processor.summary import request_summary
@@ -16,8 +17,11 @@ class IntentNode(BaseNode):
updated_state: QueryState = state.copy()
context = state["input_context"]
request = DecisionRequest(
# Jev 只接收 JSON,会话历史作为 history 字段和本次请求放在一起
state={"history": context.history, **request_summary(context)},
# Jev 只接收 JSON,会话历史作为 history 字段和本次请求放在一起;SQL、修改记录不给,state 越大越不准
state={
"history": intent_history(context.history),
**request_summary(context),
},
decisions={"intent": build_intent_question()},
)
response = await get_decision_engine().run(request)
+62 -4
View File
@@ -6,21 +6,31 @@
import json
from langchain.agents.middleware.model_call_limit import ModelCallLimitExceededError
from langchain_core.messages import AIMessageChunk, HumanMessage
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
BaseMessage,
HumanMessage,
ToolMessage,
)
from langchain_core.output_parsers import StrOutputParser
from langgraph.config import get_stream_writer
from app.agent.qa_agent import QAContext, get_qa_agent
from app.processor.base import BaseNode
from app.processor.history import history_messages
from app.processor.history import history_messages, history_records
from app.processor.state import QueryState, run_data
from app.processor.summary import data_file_summary
from app.schemas.base import JsonObject
from app.schemas.conversation import TurnQuery
# 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行
PARAGRAPH_BREAK = "\n\n"
# 带数据的问答回答完后给的后续请求:意图判断会把"展示一下 9 月销售额"这类说法分到问答,
# 用户其实想在大屏上看到;回答之后给一个按钮,点了按普通请求走大屏 agent,会话历史里有这次回答
PUT_ON_SCREEN = "把上面的结果放到大屏上"
# 一轮最多记下的查询;Langfuse 里每条 trace 的 query_data 调用 p50 3 次、p90 7 次(含重试)
MAX_QUERY_RECORDS = 10
class QANode(BaseNode):
@@ -46,11 +56,17 @@ class QANode(BaseNode):
]
agent = get_qa_agent(with_data=bool(context.data_files))
# 此前各轮的修改、查询和澄清记录跟着本次请求,不拼进历史消息;前端带来的同名字段不保留
payload.pop("history_records", None)
if records := history_records(context.history, context.screen):
payload["history_records"] = records
parser = StrOutputParser()
parts: list[str] = []
last_message_id = None
messages: list[BaseMessage] = []
try:
async for chunk, metadata in agent.astream(
async for mode, data in agent.astream(
{
"messages": [
# 会话历史作为此前的对话消息,本次请求是最后一条用户消息
@@ -59,8 +75,13 @@ class QANode(BaseNode):
]
},
context=QAContext(data=run_data()),
stream_mode="messages",
# messages 推送文字,values 留下完整的消息,用来记下执行过的查询
stream_mode=["messages", "values"],
):
if mode == "values":
messages = data["messages"]
continue
chunk, metadata = data
# 工具结果、工具调用和中间件插入的消息都不推送
if metadata.get("langgraph_node") != "model" or not isinstance(
chunk, AIMessageChunk
@@ -98,4 +119,41 @@ class QANode(BaseNode):
updated_state["status"] = "completed"
if context.data_files:
updated_state["suggestions"] = [PUT_ON_SCREEN]
# 只记成功的问答:步数用尽时回答里也没有数字可用
updated_state["queries"] = query_records(messages)
return updated_state
def query_records(messages: list[BaseMessage]) -> list[JsonObject]:
"""问答里成功执行的 query_data:调用参数和工具结果按 tool_call_id 配对
只存 SQL 和结果的形状;按执行顺序,相同的 SQL 只留最后一次,最多 MAX_QUERY_RECORDS 条(留最近的)
"""
sql_of = {
call["id"]: call["args"].get("sql")
for message in messages
if isinstance(message, AIMessage)
for call in message.tool_calls
if call["name"] == "query_data"
}
found: dict[str, JsonObject] = {}
for message in messages:
if not isinstance(message, ToolMessage) or message.status == "error":
continue
sql = sql_of.get(message.tool_call_id)
if not isinstance(sql, str):
continue
try:
result = json.loads(str(message.content))
record = TurnQuery(
sql=sql,
columns=result["columns"],
rows=result["row_count"],
truncated=result["truncated"],
).model_dump(mode="json")
except (ValueError, KeyError, TypeError):
continue
# 重新插入,相同 SQL 的位置按最后一次执行算;按原文比较,字面量里的空白也有意义
found.pop(sql, None)
found[sql] = record
return list(found.values())[-MAX_QUERY_RECORDS:]
+55 -5
View File
@@ -18,11 +18,15 @@ from app.agent.screen_agent import (
)
from app.agent.tools import ScreenAgentContext, last_write_failed
from app.processor.base import BaseNode
from app.processor.history import history_messages
from app.processor.history import history_messages, history_records
from app.processor.state import QueryState, run_data
from app.processor.summary import request_summary
from app.resources.component import get_catalog_cache
from app.resources.material_fields import identifying_texts
from app.schemas.base import JsonObject
from app.schemas.component_resource import ComponentCatalog
from app.schemas.conversation import TurnChange
from app.schemas.screen import Screen, ScreenNode
# 评测按这句话识别"草稿因最后一次提交失败没有交付",单独核对草稿本身是否满足要求
DRAFT_REJECTED = "修改没有通过校验,这次没有生效,可以换个说法再试"
@@ -57,6 +61,44 @@ def reply_update(
return {"status": "completed", "actions": actions, "answer": reply.message}
def screen_changes(
before: Screen, after: Screen, catalog: ComponentCatalog | None
) -> list[JsonObject]:
"""原始大屏和交付后的大屏按组件做净差,同一组件的多个动作合并成一条
不重放动作:新增后又删掉的组件不出现,布局展开产生的大量移动、缩放合并成一条 modified;
交付后的大屏就是草稿,交付记录在原始大屏上重放得到的也是它
"""
def change(node: ScreenNode, kind: str) -> JsonObject:
texts = identifying_texts(catalog, node.model_dump(mode="json"))
return TurnChange(
node_id=node.id,
type=node.type,
title=texts[0] if texts else node.name,
change=kind,
).model_dump(mode="json")
old = {node.id: node for node in before.nodes}
new = {node.id: node for node in after.nodes}
changes: list[JsonObject] = []
if before.canvas.model_dump(mode="json") != after.canvas.model_dump(mode="json"):
changes.append(
TurnChange(node_id="canvas", type="canvas", change="modified").model_dump(
mode="json"
)
)
for node_id, node in new.items():
if node_id not in old:
changes.append(change(node, "added"))
elif node.model_dump(mode="json") != old[node_id].model_dump(mode="json"):
changes.append(change(node, "modified"))
changes.extend(
change(node, "deleted") for node_id, node in old.items() if node_id not in new
)
return changes
class ScreenAgentNode(BaseNode):
name = "screen_agent_node"
@@ -70,14 +112,16 @@ class ScreenAgentNode(BaseNode):
context = state["input_context"]
agent = await get_screen_agent(with_data=bool(context.data_files))
request = request_summary(context)
# 此前各轮的修改、查询和澄清记录跟着本次请求,不拼进历史消息
if records := history_records(context.history, context.screen):
request["history_records"] = records
result = await agent.ainvoke(
{
# 会话历史作为此前的对话消息,本次请求是最后一条用户消息
"messages": [
*history_messages(context.history),
HumanMessage(
content=json.dumps(request_summary(context), ensure_ascii=False)
),
HumanMessage(content=json.dumps(request, ensure_ascii=False)),
]
},
context=ScreenAgentContext(
@@ -97,5 +141,11 @@ class ScreenAgentNode(BaseNode):
)
reply = await finalize_reply(result, limit_hit=limit_hit)
result = {**result, "structured_response": reply}
updated_state.update(reply_update(result, limit_hit=limit_hit, catalog=catalog))
update = reply_update(result, limit_hit=limit_hit, catalog=catalog)
if update.get("actions"):
# reply_update 拿不到原始大屏,修改记录在这里按原始大屏和草稿算
update["changes"] = screen_changes(
context.screen, result["working_screen"], catalog
)
updated_state.update(update)
return updated_state