Version: 0.7.5.dev.260324
🐛 fix(agent/schedulerefine): 修复复合微调分支链路问题,并将 MinContextSwitch 重构为固定坑位重排语义 - 🔧 修复 `schedulerefine` 复合路由中参数透传不完整、缺少 deterministic objective 时错误降级,以及“复合工具执行成功”与“终审通过”语义混淆的问题 - ✅ 保证新的独立复合分支能够正确执行、正确出站,并统一交由 `hard_check` 裁决最终结果 - 🔍 排查时发现 `MinContextSwitch` 上游 `context_tag` 存在整体退化为 `General` 的风险,影响MinContextSwitch - 🛡️ 为 `MinContextSwitch` 增加兜底策略:当标签整体退化时,按任务名关键词推断学科分组,避免分组能力失效 - ♻️ 将 `MinContextSwitch` 从“整周重新寻找新坑位”调整为“坑位不变,任务顺序改变” - 🎯 将落地方式从顺序 `BatchMove` 改为固定坑位原子重写,避免出现远距离跳位、跨天错迁、异常嵌入课位及循环换位冲突 - 🧹 修复 `hard_check` 在 `MinContextSwitch` 成功后仍执行 `origin_rank` 顺序归位、并导致逆序终审误判的问题 - 🚦 命中该分支后跳过顺序归位与顺序硬校验,避免 `summary` / `hard_check` 将有效重排结果误判为失败 📈 当前连续微调规划涉及的全部功能已可以稳定运行;下一步将继续扩展能力边界,并进一步优化 `schedule_plan` 流程 ♻️ refactor: 重整 agent2 架构,并迁移 quicknote/chat 新链路,目前还剩3个模块未迁移,后续迁移完成后会删除原agent并将此目录命名为agent - 🏗️ 明确 `agent2` 采用“统一分层目录 + 文件分层 + 依赖注入”的重构方案,不再沿用模块目录多层嵌套结构 - 🧩 完善 `agent2` 基础骨架,统一收口 `entrance` / `router` / `llm` / `stream` / `shared` / `model` / `prompt` / `node` / `graph` 等层级职责 - 🚚 将通用路由能力迁移至 `agent2/router`,沉淀统一的 `Action`、`RoutingDecision`、控制码解析,以及 `Dispatcher` / `Resolver` 抽象 - 💬 将普通聊天链路迁移至 `agent2/chat`,复用 `stream` 的 OpenAI 兼容输出协议与 LLM usage 聚合能力 - 📝 将 `quicknote` 链路迁移到 `agent2` 新结构,拆分为 `model` / `prompt` / `llm` / `node` / `graph` 多层实现,替换对旧 `agent/quicknote` 的直接依赖 - 🔌 调整 `agentsvc` 对 `agent2` 的引用,普通聊天、通用分流与 `quicknote` 全部切换到新链路 - ✂️ 去除 graph 内部 `runner` 转接层,改为由 node 层直接持有请求级依赖,并向 graph 暴露节点方法 - 🧹 合并 `graph/quicknote` 与 `graph/quicknote_run`,删除冗余骨架文件,收敛为单一 `quicknote graph` 文件 - 📚 新增 `agent2`《通用能力接入文档》,明确公共能力边界、接入方式以及 graph/node 协作约定 - 📝 更新 `AGENTS.md`,要求后续扩展 `agent2` 通用能力时必须同步维护接入文档 ♻️ refactor: 删除了现Agent目录内Chat模块的两条冗余Prompt
This commit is contained in:
272
backend/agent2/router/action_route.go
Normal file
272
backend/agent2/router/action_route.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// ControlTimeout 表示“路由控制码”阶段的额外超时预算。
|
||||
// 说明:
|
||||
// 1. 设为 0 表示完全继承父 ctx 的 deadline,不额外截断。
|
||||
// 2. 若后续观察到路由阶段偶发超时,可按需配置一个小预算(例如 2s)。
|
||||
ControlTimeout = 0 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
// routeHeaderRegex 用于解析控制码头部。
|
||||
// 支持动作:
|
||||
// 1. quick_note_create:新增随口记任务。
|
||||
// 2. task_query:任务查询。
|
||||
// 3. schedule_plan_create:新建排程。
|
||||
// 4. schedule_plan_refine:连续对话微调排程。
|
||||
// 5. schedule_plan:历史兼容动作(解析后映射到 schedule_plan_create)。
|
||||
// 6. quick_note:历史兼容动作(解析后映射到 quick_note_create)。
|
||||
// 7. chat:普通聊天。
|
||||
routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|schedule_plan|quick_note|chat)["']?[^>]*>`)
|
||||
// routeReasonRegex 用于提取可选 reason,便于日志排障。
|
||||
routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`)
|
||||
)
|
||||
|
||||
const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。
|
||||
你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。
|
||||
|
||||
动作定义:
|
||||
1) quick_note_create:用户明确要“帮我记一下/安排一个未来要做的事/提醒我”。
|
||||
2) task_query:用户要“查任务、筛任务、按条件列任务”。
|
||||
3) schedule_plan_create:用户要“新建/生成一份排程方案”。
|
||||
4) schedule_plan_refine:用户要“基于已有排程做连续微调”(如挪动某天、限制某时段、局部改动)。
|
||||
5) chat:其余普通聊天与讨论。
|
||||
|
||||
优先级(冲突时按顺序):
|
||||
1) quick_note_create
|
||||
2) task_query
|
||||
3) schedule_plan_refine
|
||||
4) schedule_plan_create
|
||||
5) chat
|
||||
|
||||
输出格式必须严格如下(两行):
|
||||
<SMARTFLOW_ROUTE nonce="给定nonce" action="quick_note_create|task_query|schedule_plan_create|schedule_plan_refine|chat"></SMARTFLOW_ROUTE>
|
||||
<SMARTFLOW_REASON>一句不超过30字的中文理由</SMARTFLOW_REASON>
|
||||
|
||||
禁止输出任何其他内容。`
|
||||
|
||||
// Action 是 agent2 路由层对业务动作的统一命名。
|
||||
//
|
||||
// 这里直接定义在 router 包,而不是复用旧 route 包:
|
||||
// 1. 当前这轮迁移要求只有 router 可以保留对旧链路的兼容语义;
|
||||
// 2. chat / quicknote 已经要完全切到 agent2,自然不该再依赖旧包常量;
|
||||
// 3. schedule/taskquery 尚未搬迁完成时,也能继续靠这些常量在 service 层做统一分发。
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionChat Action = "chat"
|
||||
ActionQuickNoteCreate Action = "quick_note_create"
|
||||
ActionTaskQuery Action = "task_query"
|
||||
ActionSchedulePlanCreate Action = "schedule_plan_create"
|
||||
ActionSchedulePlanRefine Action = "schedule_plan_refine"
|
||||
|
||||
// ActionSchedulePlan 是历史兼容动作值。
|
||||
// 说明:旧模型可能返回 schedule_plan,解析后统一映射到 schedule_plan_create。
|
||||
ActionSchedulePlan Action = "schedule_plan"
|
||||
// ActionQuickNote 是历史兼容动作值,解析后统一映射到 quick_note_create。
|
||||
ActionQuickNote Action = "quick_note"
|
||||
)
|
||||
|
||||
// ControlDecision 表示“模型控制码解析结果”。
|
||||
type ControlDecision struct {
|
||||
Action Action
|
||||
Reason string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// RoutingDecision 是服务层使用的统一分流结果。
|
||||
// 职责边界:
|
||||
// 1. Action:最终动作(chat/quick_note_create/task_query/schedule_plan_create/schedule_plan_refine)。
|
||||
// 2. TrustRoute:是否允许下游跳过二次意图判定。
|
||||
// 3. Detail:可选说明,用于阶段提示或日志。
|
||||
// 4. RouteFailed:标记“控制码路由是否失败”,供上层决定是否直接报错。
|
||||
type RoutingDecision struct {
|
||||
Action Action
|
||||
TrustRoute bool
|
||||
Detail string
|
||||
RouteFailed bool
|
||||
}
|
||||
|
||||
// DecideActionRouting 通过“模型控制码”决定本次请求走向。
|
||||
// 返回语义:
|
||||
// 1. Action=quick_note_create:进入随口记链路。
|
||||
// 2. Action=task_query:进入任务查询链路。
|
||||
// 3. Action=schedule_plan_create:进入新建排程链路。
|
||||
// 4. Action=schedule_plan_refine:进入连续微调链路。
|
||||
// 5. Action=chat:进入普通聊天链路。
|
||||
// 6. 路由失败时标记 RouteFailed=true,由上层统一处理。
|
||||
func DecideActionRouting(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("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d",
|
||||
err, time.Until(deadline).Milliseconds(), ControlTimeout.Milliseconds())
|
||||
} else {
|
||||
log.Printf("通用分流控制码失败,标记路由失败并等待上层报错: err=%v parent_deadline=none route_timeout_ms=%d",
|
||||
err, ControlTimeout.Milliseconds())
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: true,
|
||||
}
|
||||
}
|
||||
|
||||
switch decision.Action {
|
||||
case ActionQuickNoteCreate:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = "识别到新增任务请求,准备执行随口记流程。"
|
||||
}
|
||||
return RoutingDecision{Action: ActionQuickNoteCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
|
||||
case ActionTaskQuery:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = "识别到任务查询请求,准备执行任务查询流程。"
|
||||
}
|
||||
return RoutingDecision{Action: ActionTaskQuery, TrustRoute: true, Detail: reason, RouteFailed: false}
|
||||
case ActionSchedulePlanCreate:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = "识别到新建排程请求,准备执行智能排程流程。"
|
||||
}
|
||||
return RoutingDecision{Action: ActionSchedulePlanCreate, TrustRoute: true, Detail: reason, RouteFailed: false}
|
||||
case ActionSchedulePlanRefine:
|
||||
reason := strings.TrimSpace(decision.Reason)
|
||||
if reason == "" {
|
||||
reason = "识别到排程微调请求,准备执行连续微调流程。"
|
||||
}
|
||||
return RoutingDecision{Action: ActionSchedulePlanRefine, TrustRoute: true, Detail: reason, RouteFailed: false}
|
||||
case ActionChat:
|
||||
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: false}
|
||||
default:
|
||||
log.Printf("通用分流出现未知动作,标记路由失败并等待上层报错: action=%s raw=%s", decision.Action, decision.Raw)
|
||||
return RoutingDecision{Action: ActionChat, TrustRoute: false, Detail: "", RouteFailed: true}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 1. 调用目的:路由场景只需要稳定、短文本、禁用 thinking 的结构化输出。
|
||||
// 2. 这里复用 agent2 公共 LLM 封装,删除与 quicknote 重复的 JSON/文本调用样板代码。
|
||||
resp, err := agentllm.CallArkText(routeCtx, selectedModel, routeControlPrompt, userPrompt, agentllm.ArkCallOptions{
|
||||
Temperature: 0,
|
||||
MaxTokens: 120,
|
||||
Thinking: agentllm.ThinkingModeDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseRouteControlTag(resp, 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)
|
||||
}
|
||||
|
||||
// ParseRouteControlTag 解析通用控制码返回。
|
||||
// 容错策略:
|
||||
// 1. 允许大小写、属性顺序、额外属性差异;
|
||||
// 2. nonce 必须精确匹配;
|
||||
// 3. 兼容旧 action 值(schedule_plan/quick_note)。
|
||||
func ParseRouteControlTag(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)
|
||||
switch action {
|
||||
case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlanCreate, ActionSchedulePlanRefine, ActionChat:
|
||||
// 合法动作直接通过。
|
||||
case ActionQuickNote:
|
||||
action = ActionQuickNoteCreate
|
||||
case ActionSchedulePlan:
|
||||
action = ActionSchedulePlanCreate
|
||||
default:
|
||||
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
|
||||
}
|
||||
|
||||
// DecideQuickNoteRouting 是历史兼容入口。
|
||||
// 说明:
|
||||
// 1. 旧代码只区分“是否进入 quick_note”;
|
||||
// 2. 新分流中 task_query/schedule_plan_* 都不应进入 quick_note。
|
||||
func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision {
|
||||
decision := DecideActionRouting(ctx, selectedModel, userMessage)
|
||||
if decision.Action == ActionQuickNoteCreate {
|
||||
return decision
|
||||
}
|
||||
return RoutingDecision{
|
||||
Action: ActionChat,
|
||||
TrustRoute: false,
|
||||
Detail: "",
|
||||
RouteFailed: decision.RouteFailed,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseQuickNoteRouteControlTag 是历史兼容解析入口。
|
||||
// 说明:旧测试仍使用该方法名,内部统一委托 ParseRouteControlTag。
|
||||
func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) {
|
||||
return ParseRouteControlTag(raw, expectedNonce)
|
||||
}
|
||||
67
backend/agent2/router/route.go
Normal file
67
backend/agent2/router/route.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Dispatcher 是 agent2 的统一分发器。
|
||||
type Dispatcher struct {
|
||||
resolver Resolver
|
||||
handlers map[Action]SkillHandler
|
||||
}
|
||||
|
||||
// NewDispatcher 创建统一分发器。
|
||||
func NewDispatcher(resolver Resolver) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
resolver: resolver,
|
||||
handlers: make(map[Action]SkillHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册某个动作的处理函数。
|
||||
func (d *Dispatcher) Register(action Action, handler SkillHandler) error {
|
||||
if d == nil {
|
||||
return errors.New("dispatcher is nil")
|
||||
}
|
||||
if action == "" {
|
||||
return errors.New("route action is empty")
|
||||
}
|
||||
if handler == nil {
|
||||
return fmt.Errorf("handler for action %s is nil", action)
|
||||
}
|
||||
if _, exists := d.handlers[action]; exists {
|
||||
return fmt.Errorf("handler for action %s already registered", action)
|
||||
}
|
||||
d.handlers[action] = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dispatch 执行“分流 -> skill handler”完整入口。
|
||||
func (d *Dispatcher) Dispatch(ctx context.Context, req *AgentRequest) (*AgentResponse, error) {
|
||||
if d == nil || d.resolver == nil {
|
||||
return nil, errors.New("route dispatcher is not ready")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, errors.New("agent request is nil")
|
||||
}
|
||||
|
||||
// 1. 调用目的:统一先走一级路由,让入口层只关心“请求来了”,
|
||||
// 不需要提前知道这是普通聊天、随口记、任务查询还是后续排程。
|
||||
decision, err := d.resolver.Resolve(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decision == nil {
|
||||
return nil, errors.New("route decision is nil")
|
||||
}
|
||||
|
||||
// 2. 路由结果出来后,只根据 action 查找对应 handler。
|
||||
// 这里故意不做 skill 级 fallback,避免路由层和 skill 内部职责再次缠在一起。
|
||||
handler, exists := d.handlers[decision.Action]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no handler registered for action %s", decision.Action)
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
34
backend/agent2/router/route_model.go
Normal file
34
backend/agent2/router/route_model.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Resolver 定义一级路由器能力。
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, req *AgentRequest) (*RoutingDecision, error)
|
||||
}
|
||||
|
||||
// SkillHandler 是某个 skill 的统一执行入口。
|
||||
type SkillHandler func(ctx context.Context, req *AgentRequest) (*AgentResponse, error)
|
||||
|
||||
// AgentRequest 是 agent2 路由层可见的最小请求结构。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 让 router 层只依赖自己真正关心的字段;
|
||||
// 2. 避免把整份 agentmodel 结构在迁移早期层层透传;
|
||||
// 3. 后续若总入口还要追加别的字段,只需要在入口层做一次映射。
|
||||
type AgentRequest struct {
|
||||
UserID int
|
||||
ConversationID string
|
||||
UserMessage string
|
||||
ModelName string
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// AgentResponse 是路由分发器对 skill handler 的统一响应外壳。
|
||||
type AgentResponse struct {
|
||||
Action Action
|
||||
Reply string
|
||||
Meta map[string]any
|
||||
}
|
||||
Reference in New Issue
Block a user