fix: 修复会话上下文记忆链路

This commit is contained in:
2026-07-13 15:43:29 +08:00
parent dda2e040ca
commit bbd19cf9ed
12 changed files with 854 additions and 175 deletions

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from time import perf_counter
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ai_config import SystemConfig
from app.models.chat import ChatMessage, ChatSession
logger = logging.getLogger(__name__)
DEFAULT_CONTEXT_MESSAGE_COUNT = 10
MAX_CONTEXT_MESSAGE_COUNT = 100
@dataclass(frozen=True)
class SummaryUpdateResult:
trace: dict | None = None
@dataclass(frozen=True)
class _SummaryWork:
messages: list[ChatMessage]
messages_text: str
request: dict
class ChatContextService:
"""Owns the runtime policy for a session's conversational memory."""
@staticmethod
def message_limit(db: Session) -> int:
config = db.scalar(
select(SystemConfig).where(SystemConfig.config_key == "chat_context_message_count")
)
if config is None or not config.config_value.strip():
return DEFAULT_CONTEXT_MESSAGE_COUNT
try:
value = int(float(config.config_value.strip()))
except ValueError:
logger.warning(
"Invalid chat_context_message_count=%r; falling back to %s",
config.config_value,
DEFAULT_CONTEXT_MESSAGE_COUNT,
)
return DEFAULT_CONTEXT_MESSAGE_COUNT
return max(0, min(MAX_CONTEXT_MESSAGE_COUNT, value))
@classmethod
def select_memory(
cls,
db: Session,
history: list[ChatMessage] | None,
summary: str | None,
summary_up_to_message_id: int | None = None,
) -> tuple[list[ChatMessage], str | None]:
limit = cls.message_limit(db)
if limit == 0:
return [], None
recent = list((history or [])[-limit:])
if summary and summary_up_to_message_id is not None:
recent = [message for message in recent if message.id > summary_up_to_message_id]
return recent, summary
@classmethod
def update_summary(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> SummaryUpdateResult:
"""Roll older messages into one bounded summary as soon as they leave the raw window."""
work = cls._prepare_summary(db, session, history)
if work is None:
return SummaryUpdateResult()
started = perf_counter()
try:
# Local import avoids a rag_service -> context_service -> model_service cycle.
from app.services.model_service import ModelClientService
new_summary = ModelClientService.summarize_or_raise(
db,
work.messages_text,
previous_summary=session.summary,
)
return cls._apply_summary(session, work, new_summary, started)
except Exception as exc:
return cls._summary_failure(session, work, exc, started)
@classmethod
async def update_summary_async(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> SummaryUpdateResult:
work = cls._prepare_summary(db, session, history)
if work is None:
return SummaryUpdateResult()
started = perf_counter()
try:
from app.services.model_service import ModelClientService
new_summary = await ModelClientService.summarize_or_raise_async(
db,
work.messages_text,
previous_summary=session.summary,
)
return cls._apply_summary(session, work, new_summary, started)
except Exception as exc:
return cls._summary_failure(session, work, exc, started)
@classmethod
def _prepare_summary(
cls,
db: Session,
session: ChatSession,
history: list[ChatMessage],
) -> _SummaryWork | None:
limit = cls.message_limit(db)
if limit == 0 or len(history) <= limit:
return None
cutoff = len(history) - limit
start_idx = cls._covered_history_index(session, history)
if cutoff <= start_idx:
return None
messages = history[start_idx:cutoff]
messages_text = cls.messages_text(messages)
if not messages_text:
return None
return _SummaryWork(
messages=messages,
messages_text=messages_text,
request={
"previousSummary": session.summary or "",
"messages": messages_text,
"messageCount": len(messages),
"contextMessageLimit": limit,
},
)
@classmethod
def _apply_summary(
cls,
session: ChatSession,
work: _SummaryWork,
summary: str,
started: float,
) -> SummaryUpdateResult:
summary = summary.strip()
if not summary:
raise ValueError("模型返回了空的历史摘要")
session.summary = summary
session.summary_up_to_message_id = work.messages[-1].id
duration_ms = int((perf_counter() - started) * 1000)
return SummaryUpdateResult(
trace=cls._trace(
work.request,
{"summary": summary, "count": len(work.messages)},
duration_ms=duration_ms,
)
)
@classmethod
def _summary_failure(
cls,
session: ChatSession,
work: _SummaryWork,
exc: Exception,
started: float,
) -> SummaryUpdateResult:
duration_ms = int((perf_counter() - started) * 1000)
logger.exception(
"Chat history summary failed for session_id=%s message_count=%s",
session.id,
len(work.messages),
exc_info=exc,
)
return SummaryUpdateResult(
trace=cls._trace(
work.request,
{"count": 0, "fallback": "recent_messages_only"},
duration_ms=duration_ms,
status="fallback",
error=str(exc)[:500],
)
)
@staticmethod
def _covered_history_index(session: ChatSession, history: list[ChatMessage]) -> int:
if session.summary_up_to_message_id is None:
return 0
for index, message in enumerate(history):
if message.id > session.summary_up_to_message_id:
return index
return len(history)
@staticmethod
def messages_text(messages: list[ChatMessage]) -> str:
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
parts: list[str] = []
for message in messages:
content = re.sub(
r"<think[\s\S]*?</think\s*>",
"",
message.content,
flags=re.IGNORECASE,
).strip()
if content:
parts.append(f"{role_labels.get(message.role, message.role)}{content}")
return "\n".join(parts)
@staticmethod
def _trace(
request: dict,
response: dict,
*,
duration_ms: int,
status: str = "success",
error: str | None = None,
) -> dict:
return {
"tool": "chat_memory_summary",
"order": 0,
"request": request,
"response": response,
**response,
"status": status,
"startedAtMs": 0,
"endedAtMs": duration_ms,
"durationMs": duration_ms,
"responseCount": response.get("count"),
"error": error,
"retries": 0,
}

View File

@@ -12,8 +12,9 @@ from app.models.chat import ChatMessage, ChatSession
from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.external_errors import ExternalServiceError
from app.services.chat_context_service import ChatContextService
from app.services.model_service import ModelClientService
from app.services.rag_service import RagService, SUMMARY_TRIGGER_ROUNDS
from app.services.rag_service import RagService
class ChatService:
@@ -104,16 +105,16 @@ class ChatService:
)
)
# 判断是否需要生成/更新摘要
rounds = len(history) // 2 # 1 轮 = user + assistant
if rounds >= SUMMARY_TRIGGER_ROUNDS:
ChatService._maybe_update_summary(db, session, history)
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
# 构建 prompt传入历史 + 摘要)
rag_result = RagService.build_result(
db, user, normalized_question,
history=history,
session_summary=session.summary,
summary_up_to_message_id=session.summary_up_to_message_id,
context_trace=context_trace,
)
completion = ModelClientService.complete(db, rag_result)
except ExternalServiceError as exc:
@@ -190,53 +191,7 @@ class ChatService:
@staticmethod
def _maybe_update_summary(db: Session, session: ChatSession, history: list[ChatMessage]) -> None:
"""当历史超过阈值时,生成/更新摘要。摘要失败不阻断主流程。"""
if not history:
return
# 计算需要摘要的部分:已有摘要覆盖到哪条消息之后的部分
start_idx = 0
if session.summary_up_to_message_id is not None:
for i, msg in enumerate(history):
if msg.id <= session.summary_up_to_message_id:
start_idx = i + 1
else:
break
# 需要摘要的消息 = 已有摘要之后、到最近 KEEP_RECENT*2 条之前的部分
from app.services.rag_service import KEEP_RECENT
end_idx = max(start_idx, len(history) - KEEP_RECENT * 2)
if end_idx <= start_idx:
return # 没有新的需要摘要的内容
to_summarize = history[start_idx:end_idx]
if not to_summarize:
return
# 拼接成文本
import re
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
parts = []
for msg in to_summarize:
content = re.sub(r"<think[\s\S]*?</think\s*>", "", msg.content, flags=re.IGNORECASE).strip()
if content:
parts.append(f"{role_labels.get(msg.role, msg.role)}{content}")
messages_text = "\n".join(parts)
if not messages_text:
return
# 调用模型生成摘要
new_summary = ModelClientService.summarize(db, messages_text)
if new_summary:
# 追加到已有摘要后面
combined = session.summary or ""
if combined:
combined += "\n"
combined += new_summary
session.summary = combined
session.summary_up_to_message_id = to_summarize[-1].id
ChatContextService.update_summary(db, session, history)
@staticmethod
def _ensure_quota(user: User) -> None:

View File

@@ -15,6 +15,7 @@ from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User
from app.services.ai_request_log_service import AiRequestLogService
from app.services.chat_service import ChatService, _title_from_question
from app.services.chat_context_service import ChatContextService
from app.services.external_errors import ExternalServiceError
from app.services.human_attention_service import HumanAttentionService
from app.services.model_stream_service import ModelStreamService
@@ -53,7 +54,8 @@ class ChatStreamService:
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
_maybe_update_summary(db, session, history)
summary_result = ChatContextService.update_summary(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
started_at = perf_counter()
rag_result = None
@@ -61,7 +63,15 @@ class ChatStreamService:
answer_parts: list[str] = []
try:
rag_result = _build_rag_result(db, user, normalized_question, history, getattr(session, "summary", None))
rag_result = _build_rag_result(
db,
user,
normalized_question,
history,
getattr(session, "summary", None),
getattr(session, "summary_up_to_message_id", None),
context_trace,
)
model_response = ModelStreamService.stream(db, rag_result)
for chunk in model_response.chunks:
if chunk:
@@ -178,7 +188,8 @@ class ChatStreamService:
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
)
)
_maybe_update_summary(db, session, history)
summary_result = await ChatContextService.update_summary_async(db, session, history)
context_trace = [summary_result.trace] if summary_result.trace else None
started_at = perf_counter()
rag_result = None
@@ -192,7 +203,9 @@ class ChatStreamService:
normalized_question,
history=history,
session_summary=getattr(session, "summary", None),
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
session_id=session.id,
context_trace=context_trace,
)
model_response = ModelStreamService.stream_async(db, rag_result)
async for chunk in model_response.chunks:
@@ -355,24 +368,24 @@ def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms:
db.add(retrieval_log)
def _build_rag_result(db: Session, user: User, question: str, history: list[ChatMessage], summary: str | None):
def _build_rag_result(
db: Session,
user: User,
question: str,
history: list[ChatMessage],
summary: str | None,
summary_up_to_message_id: int | None,
context_trace: list[dict] | None = None,
):
parameters = signature(RagService.build_result).parameters
if "history" in parameters:
return RagService.build_result(db, user, question, history=history, session_summary=summary)
return RagService.build_result(
db,
user,
question,
history=history,
session_summary=summary,
summary_up_to_message_id=summary_up_to_message_id,
context_trace=context_trace,
)
return RagService.build_result(db, user, question)
def _maybe_update_summary(db: Session, session, history: list[ChatMessage]) -> None:
summary_handler = getattr(ChatService, "_maybe_update_summary", None)
if not callable(summary_handler) or not hasattr(session, "summary_up_to_message_id"):
return
try:
from app.services import rag_service
trigger_rounds = getattr(rag_service, "SUMMARY_TRIGGER_ROUNDS", None)
if trigger_rounds is None:
return
if len(history) // 2 >= trigger_rounds:
summary_handler(db, session, history)
except Exception:
return

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.ai_config import ModelConfig
from app.services.chat_context_service import ChatContextService
from app.models.knowledge import (
Knowledge,
KnowledgeChunk,
@@ -55,10 +56,12 @@ class KnowledgeAgentService:
question: str,
history=None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
session_id: int | None = None,
user_id: int | None = None,
version_overrides: dict[int, int] | None = None,
preview_knowledge_ids: list[int] | None = None,
context_trace: list[dict] | None = None,
) -> RagResult:
started = perf_counter()
catalog = cls.get_knowledge_catalog(
@@ -77,16 +80,33 @@ class KnowledgeAgentService:
db.add(log)
db.flush()
scopes = [KnowledgeScope(id=item["knowledgeId"], name=item["name"], feishu_space_id="", feishu_node_id="") for item in catalog]
trace: list[dict] = [cls._trace("get_knowledge_catalog", 1, {}, {"items": catalog, "count": len(catalog)}, started)]
need_knowledge, selected_ids, reason = cls._decide(question, catalog)
trace.append(cls._trace("agent_decision", 2, {"question": question}, {"needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason}, started))
trace: list[dict] = list(context_trace or [])
trace.append(cls._trace("get_knowledge_catalog", len(trace) + 1, {}, {"items": catalog, "count": len(catalog)}, started))
retrieval_question, rewrite_trace = await cls._rewrite_question(
db,
question,
history=history,
session_summary=session_summary,
summary_up_to_message_id=summary_up_to_message_id,
started=started,
order=len(trace) + 1,
)
trace.append(rewrite_trace)
need_knowledge, selected_ids, reason = cls._decide(retrieval_question, catalog)
trace.append(cls._trace(
"agent_decision",
len(trace) + 1,
{"question": retrieval_question, "originalQuestion": question},
{"needKnowledge": need_knowledge, "selectedKnowledgeIds": selected_ids, "reason": reason},
started,
))
chunks: list[RetrievedChunk] = []
try:
if need_knowledge and selected_ids:
terms = cls._query_terms(question)
terms = cls._query_terms(retrieval_question)
candidates = cls.search_knowledge(db, terms, selected_ids, catalog)
trace.append(cls._trace("search_knowledge", len(trace) + 1, {"queryTerms": terms, "knowledgeIds": selected_ids}, {"candidateCount": len(candidates), "candidates": [cls._candidate_trace(x) for x in candidates]}, started))
await cls._rerank(db, question, candidates, trace, started)
await cls._rerank(db, retrieval_question, candidates, trace, started)
selected = cls._select_sections(candidates)
chunks = [
RetrievedChunk(
@@ -111,14 +131,23 @@ class KnowledgeAgentService:
log.tool_cost_ms = int((perf_counter() - started) * 1000)
log.status = "prepared"
db.add(log)
messages = PromptService.build_messages(
db,
question,
chunks,
history,
session_summary,
summary_up_to_message_id,
)
return RagResult(
question=question,
knowledge_scopes=scopes,
chunks=chunks,
prompt=PromptService.build_prompt(db, question, chunks, history, session_summary),
prompt=PromptService.render_messages(messages),
allow_general_knowledge=not need_knowledge,
retrieval_log_id=log.id,
tool_trace=trace,
messages=messages,
)
except Exception as exc:
log.status = "failed"
@@ -128,6 +157,101 @@ class KnowledgeAgentService:
db.add(log)
raise
@classmethod
async def _rewrite_question(
cls,
db: Session,
question: str,
*,
history,
session_summary: str | None,
summary_up_to_message_id: int | None,
started: float,
order: int,
) -> tuple[str, dict]:
recent, summary = ChatContextService.select_memory(
db,
history,
session_summary,
summary_up_to_message_id,
)
if not recent or not cls._needs_contextual_rewrite(question):
return question, cls._trace(
"rewrite_contextual_question",
order,
{"question": question, "historyMessageCount": len(recent)},
{"rewrittenQuestion": question, "mode": "not_needed", "count": 1},
started,
)
history_text = ChatContextService.messages_text(recent)
request = {
"question": question,
"summary": summary or "",
"recentHistory": history_text,
}
fallback = cls._fallback_rewrite(question, recent)
model = db.scalar(
select(ModelConfig).where(ModelConfig.enabled == 1).order_by(ModelConfig.id.desc()).limit(1)
)
if model is None or _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
return fallback, cls._trace(
"rewrite_contextual_question",
order,
request,
{"rewrittenQuestion": fallback, "mode": "deterministic_fallback", "count": 1},
started,
status="fallback",
)
prompt = (
"你是对话问题改写器。结合历史,把当前追问改写成可独立理解、适合知识检索的完整问题。"
"不得回答问题,不得添加历史中没有的事实,只输出改写后的一个问题。\n\n"
f"历史摘要:\n{summary or ''}\n\n最近对话:\n{history_text}\n\n当前追问:\n{question}"
)
rag = RagResult(question=question, knowledge_scopes=[], chunks=[], prompt=prompt, allow_general_knowledge=True)
try:
rewritten = (await asyncio.to_thread(
_call_configured_model,
model,
rag,
allow_no_hit=True,
)).strip().strip('"“”')
if not rewritten or len(rewritten) > 500:
raise ValueError("问题改写结果为空或过长")
return rewritten, cls._trace(
"rewrite_contextual_question",
order,
request,
{"rewrittenQuestion": rewritten, "mode": "model", "count": 1},
started,
)
except Exception as exc:
return fallback, cls._trace(
"rewrite_contextual_question",
order,
request,
{"rewrittenQuestion": fallback, "mode": "deterministic_fallback", "count": 1},
started,
status="fallback",
error=str(exc)[:300],
)
@staticmethod
def _needs_contextual_rewrite(question: str) -> bool:
text = question.strip()
if len(text) <= 16:
return True
return bool(re.search(r"(这个|那个|上述|上面|前面|其中|第二|继续|然后呢|怎么办|怎么做|什么意思|为什么)$", text))
@staticmethod
def _fallback_rewrite(question: str, history) -> str:
previous_user = next(
(message.content.strip() for message in reversed(history) if message.role == "user" and message.content.strip()),
"",
)
return f"关于“{previous_user}”的追问:{question.strip()}" if previous_user else question.strip()
@staticmethod
def get_knowledge_catalog(
db: Session,

View File

@@ -48,16 +48,45 @@ class ModelClientService:
)
@staticmethod
def summarize(db: Session, messages_text: str) -> str | None:
def summarize(
db: Session,
messages_text: str,
previous_summary: str | None = None,
) -> str | None:
"""调用模型生成对话摘要,失败时返回 None不阻断主流程"""
model = ModelClientService._get_enabled_model(db)
if model is None:
return None
try:
return _generate_summary(model, messages_text)
return ModelClientService.summarize_or_raise(db, messages_text, previous_summary)
except Exception:
return None
@staticmethod
def summarize_or_raise(
db: Session,
messages_text: str,
previous_summary: str | None = None,
) -> str:
model = ModelClientService._get_enabled_model(db)
if _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
combined = "\n".join(part for part in [previous_summary, messages_text] if part).strip()
return combined[-1000:]
if model is None:
raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model")
return _generate_summary(model, messages_text, previous_summary)
@staticmethod
async def summarize_or_raise_async(
db: Session,
messages_text: str,
previous_summary: str | None = None,
) -> str:
if _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled):
combined = "\n".join(part for part in [previous_summary, messages_text] if part).strip()
return combined[-1000:]
model = ModelClientService._get_enabled_model(db)
if model is None:
raise ExternalServiceError("未启用可用于生成历史摘要的模型", provider="model")
return await asyncio.to_thread(_generate_summary, model, messages_text, previous_summary)
@staticmethod
def _get_enabled_model(db: Session) -> ModelConfig | None:
return db.scalar(
@@ -171,12 +200,19 @@ def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_n
return _call_openai_compatible_model(model, rag_result)
def _generate_summary(model: ModelConfig, messages_text: str) -> str:
def _generate_summary(
model: ModelConfig,
messages_text: str,
previous_summary: str | None = None,
) -> str:
"""用模型把历史对话压缩成摘要"""
summary_prompt = (
"将以下对话历史压缩成一段简洁的摘要保留关键信息用户问了什么、AI回答了什么要点、"
"是否有未解决的问题)。摘要不超过 300 字,用中文输出。\n\n"
f"对话历史:\n{messages_text}"
"把已有摘要和新增历史滚动重写成一份完整、无重复的会话记忆。"
"必须覆盖:用户背景与明确事实、用户当前目标、已确认结论、用户偏好和限制、"
"尚未解决的问题、最近对话进展。后出现的修正覆盖旧信息。"
"只输出中文摘要不要解释控制在1000字以内。\n\n"
f"已有摘要:\n{previous_summary or ''}\n\n"
f"新增历史:\n{messages_text}"
)
rag_result = RagResult(
question=summary_prompt,
@@ -190,12 +226,9 @@ def _generate_summary(model: ModelConfig, messages_text: str) -> str:
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"messages": [
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"messages": _openai_messages(rag_result, model),
"stream": False,
"max_tokens": model.max_token or 1024,
"max_tokens": _max_output_tokens(model),
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
@@ -221,11 +254,12 @@ def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) ->
def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
system, messages = _system_and_turn_messages(rag_result, model)
payload = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
"max_tokens": _max_output_tokens(model),
"system": system,
"messages": messages,
"stream": False,
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
@@ -252,15 +286,20 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
_put_if_not_none(generation_config, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(generation_config, "topP", _decimal_to_float(model.top_p))
_put_if_not_none(generation_config, "topK", model.top_k)
_put_if_not_none(generation_config, "maxOutputTokens", model.max_token)
generation_config["maxOutputTokens"] = _max_output_tokens(model)
if model.response_format == "json_object":
generation_config["responseMimeType"] = "application/json"
system, messages = _system_and_turn_messages(rag_result, model)
payload = {
"systemInstruction": {
"parts": [{"text": _agent_system_prompt()}]
},
"contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}],
"systemInstruction": {"parts": [{"text": system}]},
"contents": [
{
"role": "model" if message["role"] == "assistant" else "user",
"parts": [{"text": message["content"]}],
}
for message in messages
],
}
if generation_config:
payload["generationConfig"] = generation_config
@@ -333,17 +372,23 @@ def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
elif "GroupId" not in base_url:
base_url = f"{base_url}&GroupId={urllib.parse.quote(group_id)}"
system, messages = _system_and_turn_messages(rag_result, model)
payload: dict[str, Any] = {
"model": model.model_name,
"tokens_to_generate": model.max_token or 1024,
"tokens_to_generate": _max_output_tokens(model),
"reply_constraints": {"sender_type": "BOT", "sender_name": "大本营答疑助手"},
"messages": [
{"sender_type": "USER", "sender_name": "用户", "text": rag_result.prompt},
{
"sender_type": "BOT" if message["role"] == "assistant" else "USER",
"sender_name": "大本营答疑助手" if message["role"] == "assistant" else "用户",
"text": message["content"],
}
for message in messages
],
"bot_setting": [
{
"bot_name": "大本营答疑助手",
"content": _agent_system_prompt(),
"content": system,
}
],
}
@@ -448,6 +493,88 @@ def _agent_system_prompt() -> str:
)
def _max_output_tokens(model: ModelConfig) -> int:
configured = max(1, model.max_token or 1024)
if not model.context_window:
return configured
return min(configured, max(1, model.context_window // 3))
def _openai_messages(
rag_result: RagResult,
model: ModelConfig | None = None,
) -> list[dict[str, str]]:
system, messages = _system_and_turn_messages(rag_result, model)
return [{"role": "system", "content": system}, *messages]
def _system_and_turn_messages(
rag_result: RagResult,
model: ModelConfig | None = None,
) -> tuple[str, list[dict[str, str]]]:
source = _fit_source_messages(rag_result, model)
system_parts = [message["content"] for message in source if message.get("role") == "system"]
system = "\n\n".join(system_parts).strip() or _agent_system_prompt()
turns: list[dict[str, str]] = []
for message in source:
role = message.get("role")
content = str(message.get("content", "")).strip()
if role not in {"user", "assistant"} or not content:
continue
if turns and turns[-1]["role"] == role:
turns[-1]["content"] += "\n\n" + content
else:
turns.append({"role": role, "content": content})
if turns and turns[0]["role"] == "assistant":
system += "\n\n[最近一条助手历史回复]\n" + turns.pop(0)["content"]
if not turns:
turns.append({"role": "user", "content": rag_result.prompt})
return system, turns
def _fit_source_messages(rag_result: RagResult, model: ModelConfig | None) -> list[dict[str, str]]:
fallback_messages = [
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
]
source = [dict(message) for message in (rag_result.messages or fallback_messages)]
if model is None or not model.context_window:
return source
output_budget = _max_output_tokens(model)
input_budget = max(1, model.context_window - output_budget - 64)
def token_count() -> int:
return sum(_rough_token_count(str(message.get("content", ""))) for message in source)
while token_count() > input_budget:
removable = next(
(
index
for index, message in enumerate(source[:-1])
if message.get("role") in {"user", "assistant"}
),
None,
)
if removable is None:
break
source.pop(removable)
while token_count() > input_budget:
system_indexes = [index for index, message in enumerate(source) if message.get("role") == "system"]
if not system_indexes:
break
longest = max(system_indexes, key=lambda index: len(str(source[index].get("content", ""))))
content = str(source[longest].get("content", ""))
excess_chars = max(100, (token_count() - input_budget) * 2)
target_length = max(200, len(content) - excess_chars)
if target_length >= len(content):
break
half = target_length // 2
source[longest]["content"] = content[:half] + "\n...[上下文窗口自动截断]...\n" + content[-half:]
return source
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
target[key] = value

View File

@@ -15,16 +15,18 @@ from app.models.ai_config import ModelConfig
from app.services.external_errors import ExternalServiceError
from app.services.model_service import (
_anthropic_headers,
_agent_system_prompt,
_auth_headers,
_call_configured_model,
_decimal_to_float,
_load_extra_params,
_mock_answer,
_max_output_tokens,
_openai_messages,
_put_if_not_none,
_resolve_anthropic_endpoint,
_resolve_openai_endpoint,
_rough_token_count,
_system_and_turn_messages,
_system_config_bool,
)
from app.services.rag_service import NO_HIT_ANSWER, RagResult
@@ -166,11 +168,8 @@ async def _stream_configured_model_async(model: ModelConfig, rag_result: RagResu
def _stream_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
payload: dict[str, Any] = {
"model": model.model_name,
"messages": [
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"max_tokens": model.max_token or 1024,
"messages": _openai_messages(rag_result, model),
"max_tokens": _max_output_tokens(model),
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
@@ -215,11 +214,8 @@ async def _stream_openai_compatible_model_async(model: ModelConfig, rag_result:
def _openai_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": model.model_name,
"messages": [
{"role": "system", "content": _agent_system_prompt()},
{"role": "user", "content": rag_result.prompt},
],
"max_tokens": model.max_token or 1024,
"messages": _openai_messages(rag_result, model),
"max_tokens": _max_output_tokens(model),
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
@@ -319,11 +315,12 @@ async def _iter_openai_stream_async(response: httpx.Response) -> AsyncIterator[s
def _stream_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> Iterator[str]:
system, messages = _system_and_turn_messages(rag_result, model)
payload: dict[str, Any] = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
"max_tokens": _max_output_tokens(model),
"system": system,
"messages": messages,
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
@@ -363,11 +360,12 @@ async def _stream_anthropic_messages_async(model: ModelConfig, rag_result: RagRe
def _anthropic_stream_payload(model: ModelConfig, rag_result: RagResult) -> dict[str, Any]:
system, messages = _system_and_turn_messages(rag_result, model)
payload: dict[str, Any] = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": _agent_system_prompt(),
"messages": [{"role": "user", "content": rag_result.prompt}],
"max_tokens": _max_output_tokens(model),
"system": system,
"messages": messages,
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))

View File

@@ -16,13 +16,17 @@ class AsyncRagService:
question: str,
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
session_id: int | None = None,
context_trace: list[dict] | None = None,
) -> RagResult:
return await KnowledgeAgentService.build_result(
db,
question=question,
history=history,
session_summary=session_summary,
summary_up_to_message_id=summary_up_to_message_id,
session_id=session_id,
user_id=user.id,
context_trace=context_trace,
)

View File

@@ -8,16 +8,12 @@ from sqlalchemy.orm import Session
from app.models.ai_config import Prompt
from app.models.chat import ChatMessage
from app.models.user import User
from app.services.chat_context_service import ChatContextService
from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope
NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。"
# 摘要配置:超过 KEEP_RECENT 轮的历史会被压缩成摘要
KEEP_RECENT = 5
# 触发摘要的轮数阈值(当历史总轮数 > 这个值时才触发)
SUMMARY_TRIGGER_ROUNDS = 8
@dataclass(frozen=True)
class RetrievedChunk:
@@ -41,6 +37,7 @@ class RagResult:
allow_general_knowledge: bool = False
retrieval_log_id: int | None = None
tool_trace: list[dict] | None = None
messages: list[dict[str, str]] | None = None
@property
def is_hit(self) -> bool:
@@ -60,11 +57,28 @@ class RagService:
question: str,
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
context_trace: list[dict] | None = None,
) -> RagResult:
scopes = KnowledgeAccessService.get_allowed_knowledge(db, user)
chunks = FeishuKnowledgeService.retrieve(question, scopes, db)
prompt = PromptService.build_prompt(db, question, chunks, history, session_summary)
return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
messages = PromptService.build_messages(
db,
question,
chunks,
history,
session_summary,
summary_up_to_message_id,
)
prompt = PromptService.render_messages(messages)
return RagResult(
question=question,
knowledge_scopes=scopes,
chunks=chunks,
prompt=prompt,
tool_trace=context_trace,
messages=messages,
)
class PromptService:
@@ -80,7 +94,29 @@ class PromptService:
chunks: list[RetrievedChunk],
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
) -> str:
return cls.render_messages(
cls.build_messages(
db,
question,
chunks,
history,
session_summary,
summary_up_to_message_id,
)
)
@classmethod
def build_messages(
cls,
db: Session,
question: str,
chunks: list[RetrievedChunk],
history: list[ChatMessage] | None = None,
session_summary: str | None = None,
summary_up_to_message_id: int | None = None,
) -> list[dict[str, str]]:
prompt = cls._load_active_prompt(db)
# 知识库上下文
@@ -91,52 +127,48 @@ class PromptService:
if not context:
context = "本轮没有可靠的正式知识章节。对于一般常识可以谨慎回答;涉及课程、老师观点或公司业务时必须说明依据不足,不得编造。"
parts = [prompt]
parts.append(
"[不可关闭的最低安全规则 v1]\n"
"现实危险、自伤伤人风险应优先建议立即寻求线下专业帮助;医疗、法律、财务问题不得给出替代专业意见的结论;"
"不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。"
messages: list[dict[str, str]] = [
{
"role": "system",
"content": prompt
+ "\n\n"
+ "[不可关闭的最低安全规则 v1]\n"
+ "现实危险、自伤伤人风险应优先建议立即寻求线下专业帮助;医疗、法律、财务问题不得给出替代专业意见的结论;"
+ "不得伪造老师观点或课程内容;不得输出整篇课程文章、大段连续原文,也不得通过多轮拼接还原完整资料。",
}
]
recent_history, visible_summary = ChatContextService.select_memory(
db,
history,
session_summary,
summary_up_to_message_id,
)
if visible_summary:
messages.append({"role": "system", "content": f"[历史对话摘要]\n{visible_summary}"})
for message in recent_history:
content = cls._clean_history_content(message.content)
if content and message.role in {"user", "assistant"}:
messages.append({"role": message.role, "content": content})
messages.append({"role": "system", "content": f"[本轮可靠知识上下文]\n{context}"})
messages.append({"role": "user", "content": question.strip()})
return messages
@staticmethod
def render_messages(messages: list[dict[str, str]]) -> str:
role_labels = {"system": "系统", "user": "用户", "assistant": "大本营答疑助手"}
return "\n\n".join(
f"[{role_labels.get(message['role'], message['role'])}]\n{message['content']}"
for message in messages
)
# 对话历史:摘要 + 最近 N 轮原文
history_part = cls._build_history_section(history, session_summary)
if history_part:
parts.append(history_part)
@staticmethod
def _clean_history_content(content: str) -> str:
import re
parts.append(context)
parts.append(f"用户问题:{question.strip()}")
return "\n\n".join(parts)
@classmethod
def _build_history_section(
cls,
history: list[ChatMessage] | None,
session_summary: str | None,
) -> str:
if not history:
return ""
lines: list[str] = []
# 如果有历史摘要,先放摘要
if session_summary:
lines.append(f"[历史对话摘要]\n{session_summary}")
# 只保留最近 KEEP_RECENT 轮的原文1 轮 = 1 个 user + 1 个 assistant
recent = history[-(KEEP_RECENT * 2):]
if recent:
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
for msg in recent:
label = role_labels.get(msg.role, msg.role)
content = msg.content.strip()
# 去掉 think 标签,不需要在历史中重复传递
import re
content = re.sub(r"<think[\s\S]*?</think\s*>", "", content, flags=re.IGNORECASE).strip()
if content:
lines.append(f"{label}{content}")
return "[对话历史]\n" + "\n".join(lines) if lines else ""
return re.sub(r"<think[\s\S]*?</think\s*>", "", content, flags=re.IGNORECASE).strip()
@classmethod
def _load_active_prompt(cls, db: Session) -> str: