Version: 0.9.76.dev.260505
后端: 1.阶段 6 agent / memory 服务化收口 - 新增 cmd/agent 独立进程入口,承载 agent zrpc server、agent outbox relay / consumer 和运行时依赖初始化 - 补齐 services/agent/rpc 的 Chat stream 与 conversation meta/list/timeline、schedule-preview、context-stats、schedule-state unary RPC - 新增 gateway/client/agent 与 shared/contracts/agent,将 /api/v1/agent chat 和非 chat 门面切到 agent zrpc - 收缩 gateway 本地 AgentService 装配,双 RPC 开关开启时不再初始化本地 agent 编排、LLM、RAG 和 memory reader fallback - 将 backend/memory 物理迁入 services/memory,私有实现收入 internal,保留 module/model/observe 作为 memory 服务门面 - 调整 memory outbox、memory reader 和 agent 记忆渲染链路的 import 与服务边界,cmd/memory 独占 memory worker / consumer - 关闭 gateway 侧 agent outbox worker 所有权,agent relay / consumer 由 cmd/agent 独占,gateway 仅保留 HTTP/SSE 门面与迁移期开关回退 - 更新阶段 6 文档,记录 agent / memory 当前切流点、smoke 结果,以及 backend/client 与 gateway/shared 的目录收口口径
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
|
||||
)
|
||||
|
||||
const defaultDecisionCompareMaxTokens = 600
|
||||
|
||||
// LLMDecisionOrchestrator 负责对"一条新 fact vs 一条旧记忆"做关系判断。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 每次只比较一对,是最小粒度的 LLM 调用;
|
||||
// 2. LLM 只输出 relation(关系类型),不输出 action,不输出 target ID;
|
||||
// 3. LLM 调用失败时返回 error,由上层决定是否视为 unrelated。
|
||||
type LLMDecisionOrchestrator struct {
|
||||
client *llmservice.Client
|
||||
cfg memorymodel.Config
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLLMDecisionOrchestrator 构造决策比对编排器。
|
||||
func NewLLMDecisionOrchestrator(client *llmservice.Client, cfg memorymodel.Config) *LLMDecisionOrchestrator {
|
||||
return &LLMDecisionOrchestrator{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Compare 对单条新 fact 与单条旧候选做关系判断。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 成功时返回比对结果,relation 为四种合法值之一;
|
||||
// 2. LLM 不可用或输出异常时返回 error,上层应视为 unrelated;
|
||||
// 3. 不做最终决策,最终动作由确定性汇总逻辑产出。
|
||||
func (o *LLMDecisionOrchestrator) Compare(
|
||||
ctx context.Context,
|
||||
fact memorymodel.NormalizedFact,
|
||||
candidate memorymodel.CandidateSnapshot,
|
||||
) (*memorymodel.ComparisonResult, error) {
|
||||
if o == nil || o.client == nil {
|
||||
return nil, fmt.Errorf("决策编排器未初始化")
|
||||
}
|
||||
|
||||
// 1. 构建逐对比较 prompt:极简二元判断,LLM 只输出 relation。
|
||||
systemPrompt := buildDecisionCompareSystemPrompt()
|
||||
userPrompt := buildDecisionCompareUserPrompt(fact, candidate)
|
||||
|
||||
messages := llmservice.BuildSystemUserMessages(systemPrompt, nil, userPrompt)
|
||||
|
||||
// 2. 调用 LLM 做结构化输出,温度用低值保证判断稳定。
|
||||
resp, _, err := llmservice.GenerateJSON[decisionCompareResponse](
|
||||
ctx,
|
||||
o.client,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: 0.1,
|
||||
MaxTokens: defaultDecisionCompareMaxTokens,
|
||||
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if o.logger != nil {
|
||||
o.logger.Printf("[WARN][去重] 决策比对 LLM 调用失败: memory_type=%s candidate_id=%d err=%v", fact.MemoryType, candidate.MemoryID, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 映射 LLM 输出到 ComparisonResult,MemoryID 由代码填充而非 LLM。
|
||||
result := &memorymodel.ComparisonResult{
|
||||
MemoryID: candidate.MemoryID,
|
||||
Relation: normalizeRelation(resp.Relation),
|
||||
UpdatedContent: strings.TrimSpace(resp.UpdatedContent),
|
||||
UpdatedTitle: strings.TrimSpace(resp.UpdatedTitle),
|
||||
Reason: strings.TrimSpace(resp.Reason),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decisionCompareResponse 是 LLM 逐对比较的 JSON 输出结构。
|
||||
type decisionCompareResponse struct {
|
||||
Relation string `json:"relation"`
|
||||
UpdatedContent string `json:"updated_content"`
|
||||
UpdatedTitle string `json:"updated_title"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// normalizeRelation 统一 relation 字段为小写标准形式。
|
||||
func normalizeRelation(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
// buildDecisionCompareSystemPrompt 构建逐对比较的系统 prompt。
|
||||
func buildDecisionCompareSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是一个记忆关系判断器。请判断"新事实"和"旧记忆"之间的关系。
|
||||
|
||||
关系类型:
|
||||
- duplicate:两者表达相同意思,新事实没有新信息
|
||||
- update:新事实是对旧记忆的修正、补充或更精确表述
|
||||
- conflict:新事实与旧记忆在同一话题上存在矛盾(如"喜欢X"变为"不喜欢X"、"去了A地"变为"实际去了B地"),旧记忆已过时
|
||||
- unrelated:两者说的是不同的事情,或属于同一大类下的不同偏好(如"喜欢唱歌"与"喜欢打球"是不同爱好,不矛盾)
|
||||
|
||||
输出 JSON:
|
||||
{"relation":"...","updated_content":"...","updated_title":"...","reason":"..."}
|
||||
|
||||
规则:
|
||||
1. relation=update 时,updated_content 必须写出合并后的完整内容(不是只写差异部分)
|
||||
2. 其余 relation 类型,updated_content 留空即可
|
||||
3. reason 写简短判断依据
|
||||
4. 只输出 JSON,不要输出解释或 markdown
|
||||
5. conflict 仅限同一话题内的矛盾信息;不同话题的偏好、不同领域的兴趣一律判 unrelated`)
|
||||
}
|
||||
|
||||
// buildDecisionCompareUserPrompt 构建逐对比较的用户 prompt。
|
||||
func buildDecisionCompareUserPrompt(fact memorymodel.NormalizedFact, candidate memorymodel.CandidateSnapshot) string {
|
||||
return fmt.Sprintf("新事实:【%s】%s\n旧记忆:【%s】%s",
|
||||
fact.MemoryType, fact.Content,
|
||||
candidate.MemoryType, candidate.Content,
|
||||
)
|
||||
}
|
||||
|
||||
// resolveMemoryThinkingMode 根据配置布尔值返回对应的 ThinkingMode。
|
||||
func resolveMemoryThinkingMode(enabled bool) llmservice.ThinkingMode {
|
||||
if enabled {
|
||||
return llmservice.ThinkingModeEnabled
|
||||
}
|
||||
return llmservice.ThinkingModeDisabled
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMemoryExtractMaxTokens = 1200
|
||||
defaultMemoryExtractMaxFacts = 5
|
||||
)
|
||||
|
||||
// LLMWriteOrchestrator 负责把单条对话消息转成可入库的记忆候选。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责调用 LLM 做抽取、把输出标准化成 memory_facts;
|
||||
// 2. 不负责落库,不负责任务状态机推进;
|
||||
// 3. 当 LLM 不可用或输出异常时,回退到保守的本地抽取,保证链路不完全断。
|
||||
type LLMWriteOrchestrator struct {
|
||||
client *llmservice.Client
|
||||
cfg memorymodel.Config
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLLMWriteOrchestrator 构造 LLM 版记忆写入编排器。
|
||||
func NewLLMWriteOrchestrator(client *llmservice.Client, cfg memorymodel.Config) *LLMWriteOrchestrator {
|
||||
return &LLMWriteOrchestrator{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractFacts 从单条消息中抽取可入库事实。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 成功时返回标准化后的候选事实;
|
||||
// 2. 即使 LLM 失败,也尽量返回保守的 fallback 结果,避免 worker 空转报错;
|
||||
// 3. 只有输入本身为空时才返回空结果。
|
||||
func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if o == nil || o.client == nil {
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
|
||||
messages := llmservice.BuildSystemUserMessages(
|
||||
buildMemoryExtractSystemPrompt(o.cfg.ExtractPrompt),
|
||||
nil,
|
||||
buildMemoryExtractUserPrompt(payload),
|
||||
)
|
||||
|
||||
resp, rawResult, err := llmservice.GenerateJSON[memoryExtractResponse](
|
||||
ctx,
|
||||
o.client,
|
||||
messages,
|
||||
llmservice.GenerateOptions{
|
||||
Temperature: clampTemperature(o.cfg.LLMTemperature),
|
||||
MaxTokens: defaultMemoryExtractMaxTokens,
|
||||
Thinking: resolveMemoryThinkingMode(o.cfg.LLMThinking),
|
||||
Metadata: map[string]any{
|
||||
"stage": "memory_extract",
|
||||
"user_id": payload.UserID,
|
||||
"conversation_id": payload.ConversationID,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if o.logger != nil {
|
||||
o.logger.Printf("[WARN] memory extract llm failed user_id=%d conversation_id=%s err=%v raw=%s",
|
||||
payload.UserID, payload.ConversationID, err, truncateForLog(rawResult))
|
||||
}
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
|
||||
facts := convertExtractResponse(resp)
|
||||
normalized := memoryutils.NormalizeFacts(facts)
|
||||
if len(normalized) == 0 {
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
type memoryExtractResponse struct {
|
||||
MessageIntent string `json:"message_intent"`
|
||||
Facts []memoryExtractFact `json:"facts"`
|
||||
}
|
||||
|
||||
type memoryExtractFact struct {
|
||||
MemoryType string `json:"memory_type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Importance float64 `json:"importance"`
|
||||
SensitivityLevel int `json:"sensitivity_level"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
}
|
||||
|
||||
type memoryExtractPromptInput struct {
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
AssistantID string `json:"assistant_id,omitempty"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
SourceMessageID int64 `json:"source_message_id,omitempty"`
|
||||
SourceRole string `json:"source_role"`
|
||||
SourceText string `json:"source_text"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
func buildMemoryExtractSystemPrompt(override string) string {
|
||||
override = strings.TrimSpace(override)
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
return strings.TrimSpace(`你是一个”记忆守门员”。
|
||||
你的任务是判断用户消息是否包含值得长期记住的信息,如有则提取。
|
||||
请只输出 JSON 对象,不要输出解释、不要输出 markdown。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
“message_intent”: “chitchat|task_request|knowledge_qa|preference|personal_fact|standing_instruction”,
|
||||
“facts”: [
|
||||
{
|
||||
“memory_type”: “preference|constraint|fact”,
|
||||
“title”: “短标题”,
|
||||
“content”: “完整事实内容”,
|
||||
“confidence”: 0.0,
|
||||
“importance”: 0.0,
|
||||
“sensitivity_level”: 0,
|
||||
“is_explicit”: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
意图分类规则:
|
||||
- chitchat:闲聊、寒暄、情绪表达(”你好””谢谢””我今天好累””嗯嗯”)
|
||||
- task_request:一次性任务请求(”帮我查天气””定个闹钟””帮我写个邮件”)
|
||||
- knowledge_qa:知识问答、信息查询(”什么是量子力学””北京明天多少度”)
|
||||
- preference:用户偏好、习惯、口味(”我喜欢吃辣””别用简称””我习惯用微信”)
|
||||
- personal_fact:个人事实(”我有两个孩子””我在上海工作””我老婆对花生过敏”)
|
||||
- standing_instruction:持久指令(”以后都用英文回复我””记住我的生日是3月5号”)
|
||||
|
||||
规则:
|
||||
1. 先判断 message_intent。chitchat / task_request / knowledge_qa 三类,facts 输出空数组。
|
||||
2. 只有 preference / personal_fact / standing_instruction 才提取 facts,最多 3 条。
|
||||
3. 一条消息可能同时包含任务和偏好(如”帮我查天气,记住我喜欢晴天”),此时 intent 取偏好类型,facts 只保留偏好部分。
|
||||
4. confidence 表示这条事实是否真的值得长期记,取 0 到 1。低于 0.5 的不要输出。
|
||||
5. importance 表示对后续陪伴的价值,取 0 到 1。
|
||||
6. sensitivity_level 取 0 到 2,数字越大越敏感。
|
||||
7. 用户明确说”记住”或”以后提醒我”时,is_explicit 设为 true。
|
||||
8. 宁可漏记也不要滥记。大多数消息不应该产生任何 facts。`)
|
||||
}
|
||||
|
||||
func buildMemoryExtractUserPrompt(payload memorymodel.ExtractJobPayload) string {
|
||||
request := memoryExtractPromptInput{
|
||||
UserID: payload.UserID,
|
||||
ConversationID: payload.ConversationID,
|
||||
AssistantID: payload.AssistantID,
|
||||
RunID: payload.RunID,
|
||||
SourceMessageID: payload.SourceMessageID,
|
||||
SourceRole: payload.SourceRole,
|
||||
SourceText: payload.SourceText,
|
||||
OccurredAt: payload.OccurredAt.Format("2006-01-02 15:04:05"),
|
||||
TraceID: payload.TraceID,
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(request, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("请分析这条用户消息,判断是否需要写入长期记忆:%s", payload.SourceText)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("请分析下面这条用户消息,判断 message_intent,如包含值得长期记住的信息则提取 facts。\n输入:\n%s",
|
||||
string(raw))
|
||||
}
|
||||
|
||||
func convertExtractResponse(resp *memoryExtractResponse) []memorymodel.FactCandidate {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 意图过滤:跳过不需要记忆的消息类型。
|
||||
// 兼容自定义 prompt(不返回 message_intent 时跳过此检查,保持向后兼容)。
|
||||
if intent := strings.TrimSpace(resp.MessageIntent); intent != "" {
|
||||
if isSkipIntent(intent) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Facts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]memorymodel.FactCandidate, 0, len(resp.Facts))
|
||||
for _, fact := range resp.Facts {
|
||||
memoryType := memorymodel.NormalizeMemoryType(fact.MemoryType)
|
||||
if memoryType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(fact.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
confidence := clamp01(fact.Confidence)
|
||||
if confidence == 0 {
|
||||
confidence = 0.6
|
||||
}
|
||||
|
||||
importance := clamp01(fact.Importance)
|
||||
if importance == 0 {
|
||||
importance = defaultImportanceByType(memoryType)
|
||||
}
|
||||
|
||||
result = append(result, memorymodel.FactCandidate{
|
||||
MemoryType: memoryType,
|
||||
Title: strings.TrimSpace(fact.Title),
|
||||
Content: content,
|
||||
Confidence: confidence,
|
||||
Importance: importance,
|
||||
SensitivityLevel: clampInt(fact.SensitivityLevel, 0, 2),
|
||||
IsExplicit: fact.IsExplicit,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackNormalizedFacts(payload memorymodel.ExtractJobPayload) []memorymodel.NormalizedFact {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return memoryutils.NormalizeFacts([]memorymodel.FactCandidate{
|
||||
{
|
||||
MemoryType: memorymodel.MemoryTypeFact,
|
||||
Title: buildFallbackTitle(sourceText),
|
||||
Content: sourceText,
|
||||
Confidence: 0.45,
|
||||
Importance: defaultImportanceByType(memorymodel.MemoryTypeFact),
|
||||
SensitivityLevel: 0,
|
||||
IsExplicit: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildFallbackTitle(sourceText string) string {
|
||||
runes := []rune(strings.TrimSpace(sourceText))
|
||||
if len(runes) == 0 {
|
||||
return "用户提到"
|
||||
}
|
||||
if len(runes) > 24 {
|
||||
runes = runes[:24]
|
||||
}
|
||||
return "用户提到:" + string(runes)
|
||||
}
|
||||
|
||||
func clampTemperature(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 0.1
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clampInt(v, minValue, maxValue int) int {
|
||||
if v < minValue {
|
||||
return minValue
|
||||
}
|
||||
if v > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func defaultImportanceByType(memoryType string) float64 {
|
||||
switch memoryType {
|
||||
case memorymodel.MemoryTypePreference:
|
||||
return 0.85
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
return 0.95
|
||||
default:
|
||||
return 0.6
|
||||
}
|
||||
}
|
||||
|
||||
// isSkipIntent 判断意图是否属于"不需要记忆"的类别。
|
||||
// chitchat / task_request / knowledge_qa 三类直接跳过,不产出任何候选事实。
|
||||
func isSkipIntent(intent string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(intent)) {
|
||||
case "chitchat", "task_request", "knowledge_qa":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForLog(raw *llmservice.TextResult) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(raw.Text)
|
||||
if len(text) <= 200 {
|
||||
return text
|
||||
}
|
||||
return text[:200] + "..."
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
|
||||
)
|
||||
|
||||
// WriteOrchestrator 是 Day1 的本地回退版本。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做最保守的“从 source_text 直接生成一条候选事实”;
|
||||
// 2. 不依赖 LLM,便于在模型不可用时保底;
|
||||
// 3. 后续会逐步被 LLM 版编排器取代,但不会直接删掉,方便回退。
|
||||
type WriteOrchestrator struct{}
|
||||
|
||||
func NewWriteOrchestrator() *WriteOrchestrator {
|
||||
return &WriteOrchestrator{}
|
||||
}
|
||||
|
||||
// ExtractFacts 执行最小回退链路。
|
||||
func (o *WriteOrchestrator) ExtractFacts(_ context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidates := []memorymodel.FactCandidate{{
|
||||
MemoryType: memorymodel.MemoryTypeFact,
|
||||
Title: "用户提到",
|
||||
Content: sourceText,
|
||||
Confidence: 0.6,
|
||||
Importance: 0.6,
|
||||
SensitivityLevel: 0,
|
||||
IsExplicit: false,
|
||||
}}
|
||||
return memoryutils.NormalizeFacts(candidates), nil
|
||||
}
|
||||
Reference in New Issue
Block a user