feat(config): configurable conversation history window (B3)
FastGPT maintains full history server-side; LangGraph truncated the injected history at a hardcoded 50 messages, so long conversations behaved differently across backends. - Config.AGENT_HISTORY_MESSAGES (env): max messages injected into the model prompt (recent N, both roles counted); default 50 unchanged; larger values support long conversations; 0 = no truncation, matching FastGPT's full-history semantics. Only the injected window is affected - dialogue stays fully persisted in the checkpoint. - validate(): rejects negative values; 0 and positive accepted - conversation_history reads the config (import-time class attribute, monkeypatched in tests); explicit None-check keeps 0 falsy-safe - .env.example documents the knob Verified: 225 unit tests (6 new history-window tests)
This commit is contained in:
@@ -19,6 +19,9 @@ VOICE_CONFIG=config/voice.json
|
|||||||
# 如思考开关 KIMI 网关为 {"reasoning_effort":"none"})。
|
# 如思考开关 KIMI 网关为 {"reasoning_effort":"none"})。
|
||||||
# 超时/重试缺省 60 秒 / 3 次,与 FastGPT 客户端 SLA 对齐
|
# 超时/重试缺省 60 秒 / 3 次,与 FastGPT 客户端 SLA 对齐
|
||||||
# (契约测试 test/backends/test_sla_alignment.py)。
|
# (契约测试 test/backends/test_sla_alignment.py)。
|
||||||
|
# AGENT_HISTORY_MESSAGES=50:注入模型的对话历史上限(最近 N 条,
|
||||||
|
# 双向计数);调大支持长对话;0 = 不截断(对齐 FastGPT 全量历史)。
|
||||||
|
# AGENT_HISTORY_MESSAGES=0
|
||||||
# LLM_PROVIDERS=deepseek,kimi,gpt6luna
|
# LLM_PROVIDERS=deepseek,kimi,gpt6luna
|
||||||
# LLM_PROVIDER=deepseek
|
# LLM_PROVIDER=deepseek
|
||||||
# LLM_DEEPSEEK_BASE_URL=https://api.openai.com/v1
|
# LLM_DEEPSEEK_BASE_URL=https://api.openai.com/v1
|
||||||
|
|||||||
+17
-4
@@ -108,13 +108,26 @@ async def validated_reply(
|
|||||||
raise CollectReplyError("模型回复格式、阶段转换或时间依据不合规(一次修复后仍失败)")
|
raise CollectReplyError("模型回复格式、阶段转换或时间依据不合规(一次修复后仍失败)")
|
||||||
|
|
||||||
|
|
||||||
# 工作流 chatNode 的 history 配置为 50;保留独立消息及其角色。
|
# 工作流 chatNode 的 history 配置为 50;默认对齐(Config.
|
||||||
_HISTORY_MESSAGES = 50
|
# AGENT_HISTORY_MESSAGES,env 可调大,0 = 不截断对齐 FastGPT 全量语义)。
|
||||||
|
_HISTORY_MESSAGES_DEFAULT = 50
|
||||||
|
|
||||||
|
|
||||||
def conversation_history(state: AgentState) -> list[dict[str, str]]:
|
def conversation_history(state: AgentState) -> list[dict[str, str]]:
|
||||||
"""历史不拼入 user 消息,不剥除历史 assistant 的状态前缀。"""
|
"""历史不拼入 user 消息,不剥除历史 assistant 的状态前缀。
|
||||||
return [dict(message) for message in (state.get("dialogue") or [])[-_HISTORY_MESSAGES:]]
|
|
||||||
|
截断窗口取 Config.AGENT_HISTORY_MESSAGES(import 时固化的类属性,
|
||||||
|
测试用 monkeypatch 调整);0 或负数视为不截断。
|
||||||
|
"""
|
||||||
|
from ..core.config import Config
|
||||||
|
|
||||||
|
limit = getattr(Config, "AGENT_HISTORY_MESSAGES", None)
|
||||||
|
if limit is None:
|
||||||
|
limit = _HISTORY_MESSAGES_DEFAULT
|
||||||
|
dialogue = state.get("dialogue") or []
|
||||||
|
if limit > 0:
|
||||||
|
dialogue = dialogue[-limit:]
|
||||||
|
return [dict(message) for message in dialogue]
|
||||||
|
|
||||||
|
|
||||||
def context_prompt(state: AgentState, *, time_context: dict | None = None) -> str:
|
def context_prompt(state: AgentState, *, time_context: dict | None = None) -> str:
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class Config:
|
|||||||
# 运行时选择:LLM_PROVIDER=<名>;缺省或 fake = 离线脚本模型。
|
# 运行时选择:LLM_PROVIDER=<名>;缺省或 fake = 离线脚本模型。
|
||||||
LLM_PROVIDERS = os.getenv("LLM_PROVIDERS", "")
|
LLM_PROVIDERS = os.getenv("LLM_PROVIDERS", "")
|
||||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "")
|
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "")
|
||||||
|
# 对话历史注入模型时的最大消息条数(最近 N 条,双向计数)。
|
||||||
|
# 默认 50(约 25 轮);调大支持长对话;0 = 不截断(与 FastGPT
|
||||||
|
# 服务端全量历史语义对齐)。对话正文仍随 checkpoint 全量保存,
|
||||||
|
# 本参数只影响注入提示词的历史窗口。
|
||||||
|
AGENT_HISTORY_MESSAGES = int(os.getenv("AGENT_HISTORY_MESSAGES", "50"))
|
||||||
# LLM 客户端是否信任系统代理(Windows 注册表)。缺省 False:
|
# LLM 客户端是否信任系统代理(Windows 注册表)。缺省 False:
|
||||||
# 本地网关(127.0.0.1)必须直连;外部网关若需代理设为 true。
|
# 本地网关(127.0.0.1)必须直连;外部网关若需代理设为 true。
|
||||||
LLM_HTTP_TRUST_ENV = os.getenv("LLM_HTTP_TRUST_ENV", "false").lower() in (
|
LLM_HTTP_TRUST_ENV = os.getenv("LLM_HTTP_TRUST_ENV", "false").lower() in (
|
||||||
@@ -149,6 +154,8 @@ class Config:
|
|||||||
f"Unknown LLM_PROVIDER {cls.LLM_PROVIDER!r}; "
|
f"Unknown LLM_PROVIDER {cls.LLM_PROVIDER!r}; "
|
||||||
f"configured: {sorted(profiles)}"
|
f"configured: {sorted(profiles)}"
|
||||||
)
|
)
|
||||||
|
if cls.AGENT_HISTORY_MESSAGES < 0:
|
||||||
|
raise ValueError("AGENT_HISTORY_MESSAGES must be >= 0 (0 = 不截断)")
|
||||||
if cls.AGENT_BACKEND != "fastgpt":
|
if cls.AGENT_BACKEND != "fastgpt":
|
||||||
return
|
return
|
||||||
required = [
|
required = [
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""对话历史窗口配置测试(B3):env 可调最大注入条数,0 = 不截断。
|
||||||
|
|
||||||
|
对照 FastGPT:服务端全量维护历史、客户端只发单条消息;
|
||||||
|
LangGraph 侧历史注入曾硬编码截断 50 条,超长会话两端行为不对称。
|
||||||
|
现由 Config.AGENT_HISTORY_MESSAGES 控制(默认 50 不变)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agent.collect import conversation_history
|
||||||
|
from src.core.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _dialogue(n: int) -> dict:
|
||||||
|
return {"dialogue": [
|
||||||
|
{"role": "user", "content": f"第{i}条"} for i in range(n)
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_window_keeps_last_50(monkeypatch):
|
||||||
|
"""缺省 50:与工作流 chatNode history 配置一致(现状不变)。"""
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 50)
|
||||||
|
history = conversation_history(_dialogue(60))
|
||||||
|
assert len(history) == 50
|
||||||
|
assert history[0]["content"] == "第10条"
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_window_supports_long_dialogues(monkeypatch):
|
||||||
|
"""调大窗口:长对话注入更多历史(B3 目标)。"""
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 500)
|
||||||
|
history = conversation_history(_dialogue(120))
|
||||||
|
assert len(history) == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_window_disables_truncation(monkeypatch):
|
||||||
|
"""0 = 不截断:对齐 FastGPT 全量历史语义。"""
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 0)
|
||||||
|
history = conversation_history(_dialogue(300))
|
||||||
|
assert len(history) == 300
|
||||||
|
assert history[0]["content"] == "第0条"
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_does_not_mutate_state(monkeypatch):
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 2)
|
||||||
|
state = _dialogue(10)
|
||||||
|
conversation_history(state)
|
||||||
|
assert len(state["dialogue"]) == 10 # 原文不动,截断只发生在拷贝
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_negative_window(monkeypatch):
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", -1)
|
||||||
|
with pytest.raises(ValueError, match="AGENT_HISTORY_MESSAGES"):
|
||||||
|
Config.validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_zero_and_positive(monkeypatch):
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 0)
|
||||||
|
monkeypatch.setattr(Config, "AGENT_BACKEND", "langgraph")
|
||||||
|
monkeypatch.setattr(Config, "LLM_PROVIDER", "fake")
|
||||||
|
Config.validate()
|
||||||
|
monkeypatch.setattr(Config, "AGENT_HISTORY_MESSAGES", 10)
|
||||||
|
Config.validate()
|
||||||
Reference in New Issue
Block a user