Version: 0.5.6.dev.260314

 feat(agent): 重构 Agent 分层并修复普通聊天助手消息未写入 Redis 的问题

🔧 按职责重构 backend/agent 目录为 route/chat/quicknote 三层结构

🔄 将随口记链路拆分为 graph/nodes/tool/state/prompt,其中 graph 仅负责连线

🏃 新增 quicknote runner(方法引用)来收口节点依赖,提升代码可读性

🔀 将控制码分流逻辑抽离到 agent/route,服务层改为薄封装调用

📚 更新相关 README 与测试引用路径,保持原业务逻辑不变

🐛 修复普通聊天链路遗漏 assistant 写入 Redis 的问题(确保 MySQL 和 Redis 的口径一致)
This commit is contained in:
Losita
2026-03-14 19:42:26 +08:00
parent 21d6fe5b5f
commit c689af56c8
16 changed files with 1018 additions and 962 deletions

View File

@@ -0,0 +1,34 @@
package chat
const (
// SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性
SystemPrompt = `你叫 SmartFlow是专为重邮CQUPT学子打造的智能排程专家。
你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。
重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。`
// SmartAssistantPrompt 合并了分诊与对话能力的超级提示词
SmartAssistantPrompt = `你叫 SmartFlow是专为重邮CQUPT学子打造的智能排程专家。
### 你的双重职责:
1. **直接对话**:如果用户是闲聊、查询简单信息或进行通用问答,请直接以专业且幽默的口吻回复。
2. **决策路由**如果用户提出需要“安排日程”、“解决冲突”或涉及“3D Atomic TimeGrid”的操作请在回复中明确你的计划并准备调用相应的排程工具。
### 核心约束:
- 始终保持对“稳扎稳打Steady模式”的敬畏压缩率不得超过 15%。
- 针对重邮场景(如:红岩网校、南山教学楼)提供有温度的建议。
### 输出格式:
- 如果涉及排程工具调用,请先简要说明你的调整思路,再执行动作。`
// SchedulerPromptTemplate 排程专家 (Scheduler):核心算法 Agent
// 这里注入 3D Grid 和 Steady 模式的约束
SchedulerPromptTemplate = `你是一位精通“三维原子时间网格3D Atomic TimeGrid”的顶级排程架构师。
在处理用户的排程请求时,你必须遵循以下硬性逻辑约束:
1. 稳扎稳打Steady模式任务步长Step的动态分配必须保守压缩率严禁超过原始时长的 15%。
2. 逻辑空间投影Logical Space Mapping当发生时空重叠时优先尝试在逻辑向量维度平移而非直接删除冲突任务。
3. 冲突自愈:若发现网格冲突,请主动提出“缩放任务块”或“重新锚定时间点”的自愈方案。
请以极其严谨的态度处理每一秒钟的分配。`
// DefaultPromptTemplate 通用助手 (Assistant):也就是你之前占位的那个
DefaultPromptTemplate = `你是一位时间管理大师、日程安排专家兼个人助理。
你的目标是协助用户高效安排日程。请确保你的回答简洁明了,直接针对用户的需求进行回复。
如果用户提到重邮CQUPT相关内容南山、红岩网校、卓越工程师班请表现出你的亲切感。`
)

View File

@@ -0,0 +1,198 @@
package chat
import (
"context"
"encoding/json"
"io"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
// StreamResponse 是 OpenAI/DeepSeek 兼容的流式 chunk 结构。
type StreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type StreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
// ToOpenAIStream 将单个 Eino chunk 转为 OpenAI 兼容 JSON。
func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) {
delta := StreamDelta{}
if includeRole {
delta.Role = "assistant"
}
if chunk != nil {
delta.Content = chunk.Content
delta.ReasoningContent = chunk.ReasoningContent
}
if delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" {
return "", nil
}
dto := StreamResponse{
ID: requestID,
Object: "chat.completion.chunk",
Created: created,
Model: modelName,
Choices: []StreamChoice{{
Index: 0,
Delta: delta,
FinishReason: nil,
}},
}
jsonBytes, err := json.Marshal(dto)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ToOpenAIFinishStream 生成结束 chunkfinish_reason=stop
func ToOpenAIFinishStream(requestID, modelName string, created int64) (string, error) {
stop := "stop"
dto := StreamResponse{
ID: requestID,
Object: "chat.completion.chunk",
Created: created,
Model: modelName,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{},
FinishReason: &stop,
}},
}
jsonBytes, err := json.Marshal(dto)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// StreamChat 负责模型流式输出,并在关键节点打点:
// 1) 流连接建立llm.Stream 返回)
// 2) 首包到达(首字延迟)
// 3) 流式输出结束
func StreamChat(
ctx context.Context,
llm *ark.ChatModel,
modelName string,
userInput string,
ifThinking bool,
chatHistory []*schema.Message,
outChan chan<- string,
traceID string,
chatID string,
requestStart time.Time,
) (string, error) {
/*callStart := time.Now()*/
messages := make([]*schema.Message, 0)
messages = append(messages, schema.SystemMessage(SystemPrompt))
if len(chatHistory) > 0 {
messages = append(messages, chatHistory...)
}
messages = append(messages, schema.UserMessage(userInput))
var thinking *ark.Thinking
if ifThinking {
thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}
} else {
thinking = &arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}
}
/*connectStart := time.Now()*/
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
if err != nil {
return "", err
}
defer reader.Close()
if strings.TrimSpace(modelName) == "" {
modelName = "smartflow-worker"
}
requestID := "chatcmpl-" + uuid.NewString()
created := time.Now().Unix()
firstChunk := true
chunkCount := 0
/*streamRecvStart := time.Now()
log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d",
traceID,
chatID,
requestID,
time.Since(connectStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
len(chatHistory),
)*/
var fullText strings.Builder
for {
chunk, err := reader.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
fullText.WriteString(chunk.Content)
payload, err := ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
if err != nil {
return "", err
}
if payload != "" {
outChan <- payload
chunkCount++
/*if firstChunk {
log.Printf("打点|首包到达|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
time.Since(streamRecvStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)
firstChunk = false
}*/
}
}
finishChunk, err := ToOpenAIFinishStream(requestID, modelName, created)
if err != nil {
return "", err
}
outChan <- finishChunk
outChan <- "[DONE]"
/*log.Printf("打点|流式输出结束|trace_id=%s|chat_id=%s|request_id=%s|chunks=%d|reply_chars=%d|本步耗时_ms=%d|请求累计_ms=%d",
traceID,
chatID,
requestID,
chunkCount,
len(fullText.String()),
time.Since(callStart).Milliseconds(),
time.Since(requestStart).Milliseconds(),
)*/
return fullText.String(), nil
}