✨ feat(agent): 重构 Agent 分层并修复普通聊天助手消息未写入 Redis 的问题 🔧 按职责重构 backend/agent 目录为 route/chat/quicknote 三层结构 🔄 将随口记链路拆分为 graph/nodes/tool/state/prompt,其中 graph 仅负责连线 🏃 新增 quicknote runner(方法引用)来收口节点依赖,提升代码可读性 🔀 将控制码分流逻辑抽离到 agent/route,服务层改为薄封装调用 📚 更新相关 README 与测试引用路径,保持原业务逻辑不变 🐛 修复普通聊天链路遗漏 assistant 写入 Redis 的问题(确保 MySQL 和 Redis 的口径一致)
194 lines
6.0 KiB
Go
194 lines
6.0 KiB
Go
package route
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/agent/quicknote"
|
||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||
einoModel "github.com/cloudwego/eino/components/model"
|
||
"github.com/cloudwego/eino/schema"
|
||
"github.com/google/uuid"
|
||
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||
)
|
||
|
||
const (
|
||
// ControlTimeout 是“模型控制码分流”步骤的额外子超时。
|
||
// 设为 0 表示完全跟随父请求上下文,不额外截断。
|
||
ControlTimeout = 0 * time.Second
|
||
)
|
||
|
||
var (
|
||
// 控制头格式:
|
||
// <SMARTFLOW_ROUTE nonce="xxx" action="quick_note|chat"></SMARTFLOW_ROUTE>
|
||
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`)
|
||
// 可选理由块:
|
||
// <SMARTFLOW_REASON>...</SMARTFLOW_REASON>
|
||
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
|
||
)
|
||
|
||
// Action 表示控制码路由动作。
|
||
type Action string
|
||
|
||
const (
|
||
ActionChat Action = "chat"
|
||
ActionQuickNote Action = "quick_note"
|
||
)
|
||
|
||
// ControlDecision 是“控制码解析结果”。
|
||
type ControlDecision struct {
|
||
Action Action
|
||
Reason string
|
||
Raw string
|
||
}
|
||
|
||
// RoutingDecision 是服务层最终使用的路由结果。
|
||
type RoutingDecision struct {
|
||
EnterQuickNote bool
|
||
TrustRoute bool
|
||
Detail string
|
||
}
|
||
|
||
// DecideQuickNoteRouting 通过“模型控制码”决定本次请求走向。
|
||
// 返回语义:
|
||
// 1) EnterQuickNote=true:进入 quick_note graph;
|
||
// 2) TrustRoute=true:表示可跳过 graph 二次意图判定。
|
||
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
|
||
decision, err := routeByModelControlTag(ctx, selectedModel, userMessage)
|
||
if err != nil {
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
|
||
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
|
||
} else {
|
||
log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d",
|
||
err, ControlTimeout.Milliseconds())
|
||
}
|
||
return RoutingDecision{
|
||
EnterQuickNote: true,
|
||
TrustRoute: false,
|
||
Detail: "路由判定暂不可用,已进入任务识别兜底流程。",
|
||
}
|
||
}
|
||
|
||
switch decision.Action {
|
||
case ActionQuickNote:
|
||
reason := strings.TrimSpace(decision.Reason)
|
||
if reason == "" {
|
||
reason = "模型识别到任务安排请求,准备执行随口记。"
|
||
}
|
||
return RoutingDecision{
|
||
EnterQuickNote: true,
|
||
TrustRoute: true,
|
||
Detail: reason,
|
||
}
|
||
case ActionChat:
|
||
return RoutingDecision{
|
||
EnterQuickNote: false,
|
||
TrustRoute: false,
|
||
Detail: "",
|
||
}
|
||
default:
|
||
log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw)
|
||
return RoutingDecision{
|
||
EnterQuickNote: true,
|
||
TrustRoute: false,
|
||
Detail: "路由结果异常,已进入任务识别兜底流程。",
|
||
}
|
||
}
|
||
}
|
||
|
||
func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) {
|
||
if selectedModel == nil {
|
||
return nil, fmt.Errorf("model is nil")
|
||
}
|
||
|
||
nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", ""))
|
||
routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout)
|
||
defer cancel()
|
||
|
||
nowText := time.Now().In(time.Local).Format("2006-01-02 15:04")
|
||
userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage))
|
||
|
||
resp, err := selectedModel.Generate(routeCtx, []*schema.Message{
|
||
schema.SystemMessage(quicknote.QuickNoteRouteControlPrompt),
|
||
schema.UserMessage(userPrompt),
|
||
},
|
||
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
|
||
einoModel.WithTemperature(0),
|
||
einoModel.WithMaxTokens(80),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resp == nil {
|
||
return nil, fmt.Errorf("empty route response")
|
||
}
|
||
|
||
raw := strings.TrimSpace(resp.Content)
|
||
if raw == "" {
|
||
return nil, fmt.Errorf("empty route content")
|
||
}
|
||
|
||
return ParseQuickNoteRouteControlTag(raw, nonce)
|
||
}
|
||
|
||
// deriveRouteControlContext 为“控制码路由”创建子上下文。
|
||
// 设计要点:
|
||
// 1) timeout<=0 时不加额外 deadline,仅继承父上下文;
|
||
// 2) 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。
|
||
func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||
if timeout <= 0 {
|
||
return context.WithCancel(parent)
|
||
}
|
||
if deadline, ok := parent.Deadline(); ok {
|
||
if time.Until(deadline) <= timeout {
|
||
return context.WithCancel(parent)
|
||
}
|
||
}
|
||
return context.WithTimeout(parent, timeout)
|
||
}
|
||
|
||
// ParseQuickNoteRouteControlTag 解析控制码返回。
|
||
// 容错策略:
|
||
// 1) 允许大小写、属性顺序、额外属性差异;
|
||
// 2) nonce 必须精确匹配;
|
||
// 3) action 仅允许 quick_note/chat。
|
||
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
|
||
text := strings.TrimSpace(raw)
|
||
if text == "" {
|
||
return nil, fmt.Errorf("route content is empty")
|
||
}
|
||
|
||
header := routeHeaderRegex.FindStringSubmatch(text)
|
||
if len(header) < 3 {
|
||
return nil, fmt.Errorf("route header not found: %s", text)
|
||
}
|
||
|
||
nonce := strings.ToLower(strings.TrimSpace(header[1]))
|
||
if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) {
|
||
return nil, fmt.Errorf("route nonce mismatch")
|
||
}
|
||
|
||
actionText := strings.ToLower(strings.TrimSpace(header[2]))
|
||
action := Action(actionText)
|
||
if action != ActionQuickNote && action != ActionChat {
|
||
return nil, fmt.Errorf("invalid route action: %s", actionText)
|
||
}
|
||
|
||
reason := ""
|
||
reasonMatch := routeReasonRegex.FindStringSubmatch(text)
|
||
if len(reasonMatch) >= 2 {
|
||
reason = strings.TrimSpace(reasonMatch[1])
|
||
}
|
||
|
||
return &ControlDecision{
|
||
Action: action,
|
||
Reason: reason,
|
||
Raw: text,
|
||
}, nil
|
||
}
|