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:
Losita
2026-05-05 19:31:39 +08:00
parent d7184b776b
commit 2a96f4c6f9
72 changed files with 2775 additions and 291 deletions

View File

@@ -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 输出到 ComparisonResultMemoryID 由代码填充而非 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
}

View File

@@ -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] + "..."
}

View File

@@ -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
}