feat(prompt+cache): implement 10-section prompt template, true KB regulation recall, behavior rules and multi-level caching (P4)

This commit is contained in:
WeiCHEN
2026-09-10 11:52:40 +08:00
parent b75a06cc1a
commit cf966dcf77
13 changed files with 670 additions and 108 deletions
+4
View File
@@ -119,6 +119,10 @@ func (s *Service) DocGov() *docgov.GovernanceEngine {
return s.docGov
}
func (s *Service) KB() *kb.Client {
return s.kb
}
var SupportedTasks = squad.SupportedTasks
var SupportedStages = []StageMeta{
+2
View File
@@ -6,6 +6,7 @@ import (
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/storage"
)
@@ -139,4 +140,5 @@ type BizdevService interface {
ContextManager() *ctxmgr.ContextManager
CAS() *storage.CAS
DocGov() *docgov.GovernanceEngine
KB() *kb.Client
}
+3
View File
@@ -11,6 +11,7 @@ import (
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
)
@@ -83,6 +84,8 @@ type UserProjectContext struct {
RewrittenQuery string `json:"-"`
// RewriteRules 本轮命中的改写规则列表(如 ellipsis_completion, pronoun_resolution)
RewriteRules []string `json:"-"`
// RegulationHits 知识库法规与强标检索命中项(带 CiteKey 引用键,供对话层精准引用)
RegulationHits []kb.HitItem `json:"-"`
// UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设)
UpgradeOffered bool `json:"-"`
}
+3 -1
View File
@@ -75,7 +75,9 @@ func (e *CoordinatorEngine) generateDialogueReply(
5. 若用户询问受控文档驳回/退回原因或整改要点,必须紧扣本轮问题与已知驳回理由作答,禁止复述无关的上一轮技术长文。
6. 不要声称已修改系统配置、用户身份或受控文档状态;不要伪造已执行的工作流结果。
7. 只把最终答复写入 reply.md,使用中文,不要输出无关元数据。
8. 【相关历史片段】与【项目基准与协同上下文】中涉及其他成员姓名、个人沟通习惯、私人事实的内容,仅可用于理解语境,不得在答复中复述、评价或转述给当前用户;引用项目事件只允许引用客观结论。`
8. 事实与引用:涉及法规标准、项目数据、历史结论时,优先依据【参考知识】与【项目基准与协同上下文】作答并沿用〔引用键〕格式;两者未覆盖且无把握时,明确告知"暂无充分依据",严禁编造标准编号、条款内容或项目事实。
9. 隐私与跨用户边界:【相关历史片段】与【项目基准与协同上下文】中涉及其他成员姓名、个人沟通习惯、私人事实的内容仅用于理解语境,不得在答复中复述、评价或转述给当前用户;引用项目事件仅限客观结论(如谁完成了什么交付物)。
10. 身份一致性:始终以「项目助手」自称,不得扮演医生/监管审评员等法定角色给出诊疗或注册裁决性结论,法规与型检解读须提示"以官方发文与实际检测为准"。`
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("dialogue_generating", "正在生成项目助手回复...", "assistant", 0, 0, 60)
+73 -5
View File
@@ -16,6 +16,7 @@ import (
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
)
@@ -1377,12 +1378,79 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
// handleQueryRegulations 处理强标与审评指导原则查询
func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
searchQuery := ""
if userCtx != nil && userCtx.RewrittenQuery != "" {
searchQuery = userCtx.RewrittenQuery
} else if userCtx != nil && userCtx.TaskState != nil && userCtx.TaskState.TopicAnchor != "" {
searchQuery = userCtx.TaskState.TopicAnchor
} else if userCtx != nil {
searchQuery = userCtx.Project.Name
}
if userCtx != nil && userCtx.Project.DeviceType != "" && !strings.Contains(searchQuery, userCtx.Project.DeviceType) {
searchQuery += " " + userCtx.Project.DeviceType
}
var hits []kb.HitItem
if e != nil && e.bizdevSvc != nil && e.bizdevSvc.KB() != nil && strings.TrimSpace(searchQuery) != "" {
cacheKey := ctxmgr.HashKey("regulatory_search", searchQuery)
cache := ctxmgr.GetGlobalCache()
if cached, ok := cache.Get(ctxmgr.LayerC1Search, cacheKey); ok {
if ch, ok := cached.([]kb.HitItem); ok {
hits = ch
}
} else {
kbHits, err := e.bizdevSvc.KB().Search(ctx, searchQuery, kb.SearchOptions{
Domains: []kb.KnowledgeDomain{kb.DomainRegulatory},
Limit: 5,
})
if err == nil && len(kbHits) > 0 {
hits = kbHits
cache.Set(ctxmgr.LayerC1Search, cacheKey, hits, 30)
}
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", userCtx.Project.Name))
sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n")
sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n")
sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n")
sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。")
projectName := "医疗器械项目"
if userCtx != nil && userCtx.Project.Name != "" {
projectName = userCtx.Project.Name
}
if len(hits) > 0 {
if userCtx != nil {
userCtx.RegulationHits = hits
}
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,权威依据梳理如下:\n\n", projectName))
for i, h := range hits {
cite := h.CiteKey
if cite == "" {
cite = fmt.Sprintf("〔%s〕", h.DocCode)
}
title := h.Title
if title == "" {
title = h.DocCode
}
sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite))
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath))
}
if h.Content != "" {
cText := strings.TrimSpace(h.Content)
if len([]rune(cText)) > 150 {
cText = string([]rune(cText)[:150]) + "..."
}
sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText))
}
}
sb.WriteString("\n上述标准与条款可作为型检与注册申报的权威技术支撑。如需深入比对专标或预估测试周期,请随时吩咐我。")
} else {
// 降级基准模板
sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", projectName))
sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n")
sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n")
sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n")
sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。")
}
return &CoordinationResult{
Status: StatusAccepted,
+35
View File
@@ -896,3 +896,38 @@ func TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard(t *test
}
}
func TestCoordinatorEngine_QueryRegulations_BaselineFallback(t *testing.T) {
engine := coordinator.NewCoordinatorEngine(nil)
userCtx := &coordinator.UserProjectContext{
Project: coordinator.ProjectInfo{
Name: "4K超高清荧光内窥镜系统",
DeviceType: "内窥镜",
},
RewrittenQuery: "4K超高清荧光内窥镜系统适用的国家标准和法规有哪些?",
}
recRes := &intent.IntentRecognitionResult{
TopIntent: &intent.CandidateIntent{
Intent: intent.IntentQueryRegulations,
Branch: intent.BranchNewIntentPreempt,
},
}
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
if err != nil {
t.Fatalf("process intent failed: %v", err)
}
if res.Status != coordinator.StatusAccepted {
t.Errorf("expected status ACCEPTED, got %s", res.Status)
}
if !strings.Contains(res.NaturalReply, "GB 9706.1-2020") {
t.Errorf("expected reply to contain GB 9706.1-2020, got: %s", res.NaturalReply)
}
if !strings.Contains(res.NaturalReply, "YY 9706.102-2021") {
t.Errorf("expected reply to contain YY 9706.102-2021, got: %s", res.NaturalReply)
}
}
+123 -82
View File
@@ -7,7 +7,7 @@ import (
"chat-agent-system/internal/ctxmgr"
)
// MemoryComposer 分层记忆编排器:遵循严格的 7+1 优先级自上而下组装对话 Prompt
// MemoryComposer 分层记忆编排器:遵循严格的十段式模板结构自上而下组装对话 Prompt
type MemoryComposer struct {
MaxWindowTurns int
}
@@ -19,19 +19,20 @@ func NewMemoryComposer() *MemoryComposer {
}
}
// ComposeDialoguePrompt 依据分层优先级编排完整提示词
// 注入优先级:
// ComposeDialoguePrompt 依据结构化规范编排完整提示词
// 布局结构 (自上而下):
// 1. 【工作记忆】 task_state (DST) + PendingExpectation 挂起建议 + InteractiveCard 状态
// 2. 【短期记忆】 滑动窗口近 M 轮(逐轮编号)
// 3. 【压缩记忆】 早期轮次滚动摘要
// 4. 【长期记忆-画像】 PersonalizedGuidance(工作偏好注入语)
// 5. 【长期记忆-检索】 相关历史片段 TopK≤3
// 6. 【知识注入】 法规 KB 命中 / 往期标杆案例参考
// 7. 【项目上下文】 项目基准信息、待办线索、黑板事实(声明最高优先级)
// 8. 【本轮用户】 用户本轮真实输入
// 2. 【项目基准与协同上下文】 项目/用户/器械/阶段/待办/黑板事实(最高优先级声明)
// 3. 【早期对话摘要】 压缩记忆(仅长会话出现)
// 4. 【近期对话】 短期记忆(滑动窗口近 M 轮,逐轮编号 + 范围标注)
// 5. 【相关历史片段】 按需语义检索 TopK≤3
// 6. 【参考知识】 法规 KB 命中(带 CiteKey 引用键)/ 标杆案例参考
// 7. 【用户偏好画像】 PersonalizedGuidance(沟通风格与重点关注)
// 8. 【本轮用户】 原文 + (结合上下文理解完整诉求: …)双呈现
// 9. 【答复要求】 优先级裁决、引用规范、隐私条款与输出格式
func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserProjectContext) string {
if userCtx == nil {
return fmt.Sprintf("【本轮用户】\n%s\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。", userInput)
return fmt.Sprintf("【本轮用户】\n%s\n\n【答复要求】\n请结合上下文,以温和助理口吻作答,并写入 reply.md。", userInput)
}
var sb strings.Builder
@@ -63,76 +64,7 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
}
// -------------------------------------------------------------
// 2. 【短期记忆】 滑动窗口近 M 轮 (~3000 字)
// -------------------------------------------------------------
if len(userCtx.RecentDialogue) > 0 {
sb.WriteString("【近期对话】\n")
turns := userCtx.RecentDialogue
if len(turns) > mc.MaxWindowTurns {
turns = turns[len(turns)-mc.MaxWindowTurns:]
}
for i, t := range turns {
roleName := "用户"
if t.Role == "assistant" {
roleName = "助手"
} else if t.Role == "system" {
roleName = "系统"
}
sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", i+1, roleName, t.Content))
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 3. 【压缩记忆】 早期轮次滚动摘要 (~300 字)
// -------------------------------------------------------------
if s := strings.TrimSpace(userCtx.DialogueSummary); s != "" {
sb.WriteString(s)
if !strings.HasSuffix(s, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 4. 【长期记忆-画像】 PersonalizedGuidance (~300 字)
// -------------------------------------------------------------
if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" {
sb.WriteString(g)
if !strings.HasSuffix(g, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 5. 【长期记忆-检索】 相关历史片段 TopK≤3 (~600 字)
// -------------------------------------------------------------
recalledText := strings.TrimSpace(userCtx.RecalledMemorySnippet)
if recalledText == "" && len(userCtx.RecalledMemories) > 0 {
recalledText = strings.TrimSpace(ctxmgr.FormatRecalledMemorySnippet(userCtx.RecalledMemories, userCtx.SnapshotID))
}
if recalledText != "" {
sb.WriteString(recalledText)
if !strings.HasSuffix(recalledText, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 6. 【知识注入】 法规 KB / 标杆案例参考 (仅特定意图, ~800 字)
// -------------------------------------------------------------
if userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0 {
sb.WriteString("【往期标杆案例参考】\n")
for _, c := range userCtx.LatestAdvisorBrief.RecommendedCases {
sb.WriteString(fmt.Sprintf("- 《%s》(%s): %s\n", c.ArtifactTitle, c.TaskKey, c.SummarySnippet))
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 7. 【项目上下文】 项目基准信息、待办事项、黑板事实 (声明最高优先级)
// 2. 【项目基准与协同上下文】 项目基准信息、待办事项、黑板事实 (声明最高优先级)
// -------------------------------------------------------------
sb.WriteString("【项目基准与协同上下文】\n")
sb.WriteString(fmt.Sprintf("- 当前项目: %s (ID: %s)\n", userCtx.Project.Name, userCtx.Project.ID))
@@ -176,6 +108,106 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
}
sb.WriteString("\n")
// -------------------------------------------------------------
// 3. 【早期对话摘要】 压缩记忆 (~300 字)
// -------------------------------------------------------------
if s := strings.TrimSpace(userCtx.DialogueSummary); s != "" {
sb.WriteString(s)
if !strings.HasSuffix(s, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 4. 【近期对话】 滑动窗口近 M 轮 (~3000 字,含范围标注)
// -------------------------------------------------------------
if len(userCtx.RecentDialogue) > 0 {
totalTurns := len(userCtx.RecentDialogue)
turns := userCtx.RecentDialogue
startIdx := 1
if totalTurns > mc.MaxWindowTurns {
turns = turns[totalTurns-mc.MaxWindowTurns:]
startIdx = totalTurns - mc.MaxWindowTurns + 1
sb.WriteString(fmt.Sprintf("【近期对话】(第 %d~%d 轮,更早对话已压缩至摘要)\n", startIdx, totalTurns))
} else {
sb.WriteString("【近期对话】\n")
}
for i, t := range turns {
roleName := "用户"
if t.Role == "assistant" {
roleName = "助手"
} else if t.Role == "system" {
roleName = "系统"
}
sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", startIdx+i, roleName, t.Content))
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 5. 【相关历史片段】 语义检索切片 TopK≤3 (~600 字)
// -------------------------------------------------------------
recalledText := strings.TrimSpace(userCtx.RecalledMemorySnippet)
if recalledText == "" && len(userCtx.RecalledMemories) > 0 {
recalledText = strings.TrimSpace(ctxmgr.FormatRecalledMemorySnippet(userCtx.RecalledMemories, userCtx.SnapshotID))
}
if recalledText != "" {
sb.WriteString(recalledText)
if !strings.HasSuffix(recalledText, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 6. 【参考知识】 法规 KB 命中(带 CiteKey)/ 往期标杆案例
// -------------------------------------------------------------
hasKnowledge := len(userCtx.RegulationHits) > 0 ||
(userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0)
if hasKnowledge {
sb.WriteString("【参考知识】(权威法规与标准依据,涉及结论时请沿用〔引用键〕引用)\n")
for _, h := range userCtx.RegulationHits {
cite := h.CiteKey
if cite == "" {
cite = fmt.Sprintf("〔%s〕", h.DocCode)
}
title := h.Title
if title == "" {
title = h.DocCode
}
sb.WriteString(fmt.Sprintf("• %s %s\n", cite, title))
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath))
}
if h.Content != "" {
cText := strings.TrimSpace(h.Content)
if len([]rune(cText)) > 150 {
cText = string([]rune(cText)[:150]) + "..."
}
sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText))
}
}
if userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0 {
for _, c := range userCtx.LatestAdvisorBrief.RecommendedCases {
sb.WriteString(fmt.Sprintf("• 《%s》(%s): %s\n", c.ArtifactTitle, c.TaskKey, c.SummarySnippet))
}
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 7. 【用户偏好画像】 PersonalizedGuidance (~300 字)
// -------------------------------------------------------------
if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" {
sb.WriteString(g)
if !strings.HasSuffix(g, "\n") {
sb.WriteString("\n")
}
sb.WriteString("\n")
}
// -------------------------------------------------------------
// 8. 【本轮用户】
// -------------------------------------------------------------
@@ -185,7 +217,16 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP
} else {
sb.WriteString(userInput)
}
sb.WriteString("\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。")
sb.WriteString("\n\n")
// -------------------------------------------------------------
// 9. 【答复要求】
// -------------------------------------------------------------
sb.WriteString("【答复要求】\n")
sb.WriteString("1. 优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。\n")
sb.WriteString("2. 事实与引用:涉及法规条款与标准时,优先依据【参考知识】与【项目基准与协同上下文】并沿用〔引用键〕格式;无把握时明确提示依据不足,严禁生造标准号。\n")
sb.WriteString("3. 隐私与跨用户边界:其他成员姓名与私人习惯仅限理解语境,禁止外泄复述。\n")
sb.WriteString("4. 答复目标:请结合上下文,以温和严谨的「项目助手」口吻作答,并写入 reply.md。")
return sb.String()
}
+40 -20
View File
@@ -9,6 +9,7 @@ import (
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
)
func TestMemoryComposer_HierarchyAndPriority(t *testing.T) {
@@ -54,30 +55,45 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) {
SimilarityScore: 0.91,
},
},
RegulationHits: []kb.HitItem{
{
DocCode: "GB 9706.1-2020",
Title: "医用电气设备 第1部分:基本安全和基本性能的通用要求",
CiteKey: "〔GB 9706.1-2020 §8.4〕",
HeadingPath: "8.4 保护接地",
Content: "设备应当设置可靠的保护接地端子,满足接地阻抗要求。",
},
},
BlackboardFacts: map[string]string{
"budget": "150万",
"device_type": "超声手术设备",
},
RewrittenQuery: "那「超声切割止血系统」后续的型检送检如何安排?",
}
prompt := composer.ComposeDialoguePrompt("那后续的型检送检如何安排?", userCtx)
// 验证包含各个优先级分层标记
// 验证包含各个结构化分层标记
sections := []string{
"【工作记忆】",
"【本轮任务状态 (Working Memory)】",
"- 上一轮挂起建议摘要: 建议补充型检预估周期",
"【项目基准与协同上下文】",
"项目黑板核心事实(最高优先级,与早期摘要或历史片段冲突时以此为准)",
"【早期对话摘要】",
"【近期对话】",
"[1] 用户: 这个项目的预算是多少?",
"[2] 助手: 一期预算建议控制在150万以内。",
"【早期对话摘要】",
"【用户偏好建议】",
"【相关历史片段】",
"[本会话·第2轮]",
"【项目基准与协同上下文】",
"项目黑板核心事实(最高优先级,与早期摘要或历史片段冲突时以此为准)",
"【参考知识】",
"〔GB 9706.1-2020 §8.4〕",
"【用户偏好建议】",
"【本轮用户】",
"那后续的型检送检如何安排?",
"用户原话: 那后续的型检送检如何安排?",
"(结合上下文理解完整诉求: 那「超声切割止血系统」后续的型检送检如何安排?)",
"【答复要求】",
"优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。",
}
for _, sec := range sections {
@@ -86,23 +102,27 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) {
}
}
// 验证层级相对顺序(自上而下):
// 工作记忆 < 近期对话 < 早期对话摘要 < 用户偏好建议 < 相关历史片段 < 项目基准与协同上下文 < 本轮用户
// 验证严格的自上而下布局顺序:
// 工作记忆 < 项目上下文 < 早期摘要 < 近期对话 < 相关历史片段 < 参考知识 < 用户偏好 < 本轮用户 < 答复要求
idxWorkMem := strings.Index(prompt, "【工作记忆】")
idxRecent := strings.Index(prompt, "【近期对话】")
idxSummary := strings.Index(prompt, "【早期对话摘要】")
idxGuidance := strings.Index(prompt, "【用户偏好建议】")
idxRecalled := strings.Index(prompt, "【相关历史片段】")
idxProject := strings.Index(prompt, "【项目基准与协同上下文】")
idxSummary := strings.Index(prompt, "【早期对话摘要】")
idxRecent := strings.Index(prompt, "【近期对话】")
idxRecalled := strings.Index(prompt, "【相关历史片段】")
idxReference := strings.Index(prompt, "【参考知识】")
idxGuidance := strings.Index(prompt, "【用户偏好建议】")
idxUser := strings.Index(prompt, "【本轮用户】")
idxReq := strings.Index(prompt, "【答复要求】")
if !(idxWorkMem < idxRecent &&
idxRecent < idxSummary &&
idxSummary < idxGuidance &&
idxGuidance < idxRecalled &&
idxRecalled < idxProject &&
idxProject < idxUser) {
t.Errorf("prompt section ordering violation! indices: workMem=%d, recent=%d, summary=%d, guidance=%d, recalled=%d, project=%d, user=%d",
idxWorkMem, idxRecent, idxSummary, idxGuidance, idxRecalled, idxProject, idxUser)
if !(idxWorkMem < idxProject &&
idxProject < idxSummary &&
idxSummary < idxRecent &&
idxRecent < idxRecalled &&
idxRecalled < idxReference &&
idxReference < idxGuidance &&
idxGuidance < idxUser &&
idxUser < idxReq) {
t.Errorf("prompt section ordering violation! indices: workMem=%d, project=%d, summary=%d, recent=%d, recalled=%d, ref=%d, guidance=%d, user=%d, req=%d",
idxWorkMem, idxProject, idxSummary, idxRecent, idxRecalled, idxReference, idxGuidance, idxUser, idxReq)
}
}
+258
View File
@@ -0,0 +1,258 @@
package ctxmgr
import (
"crypto/sha256"
"encoding/hex"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
// 缓存层级常量定义
const (
LayerC1Search = "C1" // 检索缓存 (kb.Search / SearchChatMemory), 默认 TTL 10m
LayerC2Rewrite = "C2" // 改写缓存 (RewriteResult), 默认 TTL 30m
LayerC3Embedding = "C3" // 文本向量化缓存, 默认 TTL 60m
LayerC4Context = "C4" // 上下文组装产物, 默认 TTL 5m (黑板/任务状态更新主动失效)
LayerC5Summary = "C5" // 会话滚动摘要, 默认 TTL 5m (摘要生成主动失效)
DefaultMaxEntriesPerLayer = 1000
)
type cacheEntry struct {
value any
expiresAt time.Time
costMs int64
hits int64
}
// CacheStats 缓存运行监控指标
type CacheStats struct {
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
SavedMs int64 `json:"saved_ms"`
HitRate float64 `json:"hit_rate"`
}
// MultiLevelCache 统一 LRU+TTL 内存多级缓存系统
type MultiLevelCache struct {
mu sync.RWMutex
layers map[string]map[string]*cacheEntry
defaultTTLs map[string]time.Duration
maxEntries int
enabled bool
totalHits atomic.Int64
totalMisses atomic.Int64
totalSavedMs atomic.Int64
}
var globalCache *MultiLevelCache
var globalCacheOnce sync.Once
// GetGlobalCache 获取全局多级缓存单例
func GetGlobalCache() *MultiLevelCache {
globalCacheOnce.Do(func() {
globalCache = NewMultiLevelCache()
})
return globalCache
}
// NewMultiLevelCache 创建多级缓存实例
func NewMultiLevelCache() *MultiLevelCache {
enabled := true
if env := strings.ToLower(strings.TrimSpace(os.Getenv("CACHE_ENABLED"))); env == "false" || env == "0" || env == "off" {
enabled = false
}
return &MultiLevelCache{
layers: map[string]map[string]*cacheEntry{
LayerC1Search: make(map[string]*cacheEntry),
LayerC2Rewrite: make(map[string]*cacheEntry),
LayerC3Embedding: make(map[string]*cacheEntry),
LayerC4Context: make(map[string]*cacheEntry),
LayerC5Summary: make(map[string]*cacheEntry),
},
defaultTTLs: map[string]time.Duration{
LayerC1Search: 10 * time.Minute,
LayerC2Rewrite: 30 * time.Minute,
LayerC3Embedding: 60 * time.Minute,
LayerC4Context: 5 * time.Minute,
LayerC5Summary: 5 * time.Minute,
},
maxEntries: DefaultMaxEntriesPerLayer,
enabled: enabled,
}
}
// SetEnabled 动态控制缓存开关(用于测试或运行时降级)
func (c *MultiLevelCache) SetEnabled(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.enabled = enabled
}
// IsEnabled 检查缓存是否启用
func (c *MultiLevelCache) IsEnabled() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.enabled
}
// HashKey 计算多字段 SHA256 字符串指纹
func HashKey(parts ...string) string {
h := sha256.New()
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte("|"))
}
return hex.EncodeToString(h.Sum(nil))
}
// Get 读取指定层级的缓存
func (c *MultiLevelCache) Get(layer string, key string) (any, bool) {
if c == nil || !c.IsEnabled() || key == "" {
return nil, false
}
c.mu.RLock()
layerMap, ok := c.layers[layer]
if !ok {
c.mu.RUnlock()
c.totalMisses.Add(1)
return nil, false
}
entry, found := layerMap[key]
if !found {
c.mu.RUnlock()
c.totalMisses.Add(1)
return nil, false
}
if time.Now().After(entry.expiresAt) {
c.mu.RUnlock()
// 惰性驱逐
c.Invalidate(layer, key)
c.totalMisses.Add(1)
return nil, false
}
c.mu.RUnlock()
entry.hits++
c.totalHits.Add(1)
if entry.costMs > 0 {
c.totalSavedMs.Add(entry.costMs)
}
return entry.value, true
}
// Set 写入指定层级的缓存
func (c *MultiLevelCache) Set(layer string, key string, val any, costMs int64) {
if c == nil || !c.IsEnabled() || key == "" || val == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
layerMap, ok := c.layers[layer]
if !ok {
layerMap = make(map[string]*cacheEntry)
c.layers[layer] = layerMap
}
// 容量保护:超过单层最大容量时清除超限旧条目
if len(layerMap) >= c.maxEntries {
now := time.Now()
for k, v := range layerMap {
if now.After(v.expiresAt) {
delete(layerMap, k)
}
}
if len(layerMap) >= c.maxEntries {
// 若无过期则随机驱逐10%
n := 0
for k := range layerMap {
delete(layerMap, k)
n++
if n >= c.maxEntries/10 {
break
}
}
}
}
ttl := c.defaultTTLs[layer]
if ttl <= 0 {
ttl = 10 * time.Minute
}
layerMap[key] = &cacheEntry{
value: val,
expiresAt: time.Now().Add(ttl),
costMs: costMs,
hits: 0,
}
}
// Invalidate 失效指定层级的某一个 Key
func (c *MultiLevelCache) Invalidate(layer string, key string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if layerMap, ok := c.layers[layer]; ok {
delete(layerMap, key)
}
}
// InvalidateLayer 整体清空某个层级(如黑板更新时清空 C4)
func (c *MultiLevelCache) InvalidateLayer(layer string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.layers[layer]; ok {
c.layers[layer] = make(map[string]*cacheEntry)
}
}
// Clear 清空全部层级与统计
func (c *MultiLevelCache) Clear() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for l := range c.layers {
c.layers[l] = make(map[string]*cacheEntry)
}
c.totalHits.Store(0)
c.totalMisses.Store(0)
c.totalSavedMs.Store(0)
}
// Stats 获取缓存性能与命中统计指标
func (c *MultiLevelCache) Stats() CacheStats {
if c == nil {
return CacheStats{}
}
hits := c.totalHits.Load()
misses := c.totalMisses.Load()
saved := c.totalSavedMs.Load()
rate := 0.0
if total := hits + misses; total > 0 {
rate = float64(hits) / float64(total)
}
return CacheStats{
Hits: hits,
Misses: misses,
SavedMs: saved,
HitRate: rate,
}
}
+85
View File
@@ -0,0 +1,85 @@
package ctxmgr_test
import (
"testing"
"chat-agent-system/internal/ctxmgr"
)
func TestMultiLevelCache_BasicOpsAndStats(t *testing.T) {
cache := ctxmgr.NewMultiLevelCache()
cache.Clear()
key := ctxmgr.HashKey("test_user", "test_query")
// 1. 首次读取未命中
val, ok := cache.Get(ctxmgr.LayerC1Search, key)
if ok || val != nil {
t.Fatalf("expected cache miss, got %v", val)
}
// 2. 写入并读取命中
cache.Set(ctxmgr.LayerC1Search, key, "result_items", 45)
val, ok = cache.Get(ctxmgr.LayerC1Search, key)
if !ok || val.(string) != "result_items" {
t.Fatalf("expected cache hit with 'result_items', got %v", val)
}
// 3. 校验统计指标
stats := cache.Stats()
if stats.Hits != 1 || stats.Misses != 1 {
t.Errorf("unexpected stats: hits=%d, misses=%d", stats.Hits, stats.Misses)
}
if stats.SavedMs != 45 {
t.Errorf("expected savedMs=45, got %d", stats.SavedMs)
}
if stats.HitRate != 0.5 {
t.Errorf("expected hitRate=0.5, got %f", stats.HitRate)
}
// 4. 单条 Key 失效
cache.Invalidate(ctxmgr.LayerC1Search, key)
_, ok = cache.Get(ctxmgr.LayerC1Search, key)
if ok {
t.Errorf("expected cache miss after invalidation")
}
}
func TestMultiLevelCache_LayerInvalidation(t *testing.T) {
cache := ctxmgr.NewMultiLevelCache()
cache.Clear()
k1 := ctxmgr.HashKey("u1", "p1")
k2 := ctxmgr.HashKey("u2", "p2")
cache.Set(ctxmgr.LayerC4Context, k1, "ctx_1", 20)
cache.Set(ctxmgr.LayerC4Context, k2, "ctx_2", 20)
// 整体清空 C4 层
cache.InvalidateLayer(ctxmgr.LayerC4Context)
if _, ok := cache.Get(ctxmgr.LayerC4Context, k1); ok {
t.Errorf("expected k1 in C4 to be cleared")
}
if _, ok := cache.Get(ctxmgr.LayerC4Context, k2); ok {
t.Errorf("expected k2 in C4 to be cleared")
}
}
func TestMultiLevelCache_DisabledToggle(t *testing.T) {
cache := ctxmgr.NewMultiLevelCache()
cache.SetEnabled(false)
key := ctxmgr.HashKey("k_disabled")
cache.Set(ctxmgr.LayerC2Rewrite, key, "rewritten", 10)
if _, ok := cache.Get(ctxmgr.LayerC2Rewrite, key); ok {
t.Errorf("expected cache to be inactive when disabled")
}
cache.SetEnabled(true)
cache.Set(ctxmgr.LayerC2Rewrite, key, "rewritten", 10)
if _, ok := cache.Get(ctxmgr.LayerC2Rewrite, key); !ok {
t.Errorf("expected cache to be active when enabled")
}
}
+17
View File
@@ -199,6 +199,21 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe
opts.MinScore = 0.25
}
cacheKey := HashKey(
"chat_memory",
fmt.Sprintf("%d", opts.UserID),
fmt.Sprintf("%d", opts.ProjectID),
opts.Query,
opts.SessionID,
opts.ExcludeSessionID,
fmt.Sprintf("%d", opts.TopK),
)
if cached, ok := m.Cache().Get(LayerC1Search, cacheKey); ok {
if items, ok := cached.([]RecalledMemoryItem); ok {
return items, nil
}
}
emb := m.embedder
if emb == nil {
emb = NewDeterministicEmbedder(1024)
@@ -277,6 +292,7 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe
if len(candidates) > opts.TopK {
candidates = candidates[:opts.TopK]
}
m.Cache().Set(LayerC1Search, cacheKey, candidates, 20)
return candidates, nil
}
@@ -348,6 +364,7 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe
if len(candidates) > opts.TopK {
candidates = candidates[:opts.TopK]
}
m.Cache().Set(LayerC1Search, cacheKey, candidates, 20)
return candidates, nil
}
+14
View File
@@ -29,6 +29,7 @@ type ContextManager struct {
activities map[int64][]ActivityItem // project_id -> activities
memoryChunks []MemoryChunkItem // 语义记忆切片缓存
summaries map[string]*ChatSummary // session_id -> rolling summary
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
nextActivityID int64
nextMessageID int64
nextChunkID int64
@@ -52,12 +53,24 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC
activities: make(map[int64][]ActivityItem),
memoryChunks: make([]MemoryChunkItem, 0),
summaries: make(map[string]*ChatSummary),
cache: NewMultiLevelCache(),
nextActivityID: 1,
nextMessageID: 1,
nextChunkID: 1,
}
}
// Cache 获取上下文管理器的多级缓存实例
func (m *ContextManager) Cache() *MultiLevelCache {
if m == nil {
return GetGlobalCache()
}
if m.cache == nil {
m.cache = GetGlobalCache()
}
return m.cache
}
// SetEmbedder 设置自定义向量嵌入器 (如生产环境 bge-m3)
func (m *ContextManager) SetEmbedder(emb VectorEmbedder) {
if emb != nil {
@@ -375,6 +388,7 @@ func (m *ContextManager) UpdateProjectBlackboard(ctx context.Context, projectID
return fmt.Errorf("upsert project blackboard failed: %w", err)
}
}
m.Cache().InvalidateLayer(LayerC4Context)
return nil
}
+13
View File
@@ -50,6 +50,13 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (
return nil, fmt.Errorf("sessionID required")
}
if cached, ok := m.Cache().Get(LayerC5Summary, sessionID); ok {
if sum, ok := cached.(*ChatSummary); ok && sum != nil {
copySum := *sum
return &copySum, nil
}
}
if m.pool != nil {
var sum ChatSummary
var factsJSON, openQuestionsJSON []byte
@@ -61,6 +68,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (
if err == nil {
_ = json.Unmarshal(factsJSON, &sum.Facts)
_ = json.Unmarshal(openQuestionsJSON, &sum.OpenQuestions)
m.Cache().Set(LayerC5Summary, sessionID, &sum, 15)
return &sum, nil
}
}
@@ -71,6 +79,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) (
if s, ok := m.summaries[sessionID]; ok && s != nil {
// 返回副本
copySum := *s
m.Cache().Set(LayerC5Summary, sessionID, &copySum, 15)
return &copySum, nil
}
}
@@ -84,6 +93,10 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary)
}
sum.UpdatedAt = time.Now()
// 主动失效 C5 摘要缓存与 C4 上下文缓存
m.Cache().Invalidate(LayerC5Summary, sum.SessionID)
m.Cache().InvalidateLayer(LayerC4Context)
if m.pool != nil {
factsJSON, _ := json.Marshal(sum.Facts)
openQuestionsJSON, _ := json.Marshal(sum.OpenQuestions)