feat:记忆&上下文管理
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user