d032c198c8
链路打通 tenant → 计费事实源: - 契约:Task.Meta 加 MetaTenantID;UsageEvent 加 TenantID。 - 提交:网关 task.Meta[MetaTenantID]=tenantID(c);dispatcher emitUsage 带租户。 - 明细表 sundynix_usage_event(追加式,task_id 唯一→幂等防重投重复计费): tenant/owner/model/tokens + credits_micro + cost_micros。 - 折算:credits=total_tok/TOKENS_PER_CREDIT×credit_weight(token 基准,设 1 即 token 直计); cost 按 Pricing 折算;Pricing 加 credit_weight 列(每模型积分权重,缺省 1)。 模型名空则回退激活 chat 模型(近似,忽略 failover 备用模型,已在设计标注)。 - 网关 SubscribeUsage 折算落明细(保留 Redis 日计数作快速配额校验)。 live 验证:提交任务→一行 usage_event,tenant 匹配用户租户、 credits=89tok/1000×2×1e6=178000 微积分、cost=45/1000×1+44/1000×2=133000 微元 CNY, 折算数学与幂等键均正确。 设计见 SAAS_P2_DESIGN.md。增量2(credit_ledger 余额软扣 + rollup)待做。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
552 lines
24 KiB
Go
552 lines
24 KiB
Go
// Package eino 封装基于 CloudWeGo Eino 的 Agent 图编排引擎。
|
||
package eino
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/cloudwego/eino/components/model"
|
||
"github.com/cloudwego/eino/schema"
|
||
"go.opentelemetry.io/otel/attribute"
|
||
"go.opentelemetry.io/otel/codes"
|
||
"go.opentelemetry.io/otel/trace"
|
||
|
||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
"github.com/sundynix/sundynix-shared/otelx"
|
||
"github.com/sundynix/sundynix-shared/prompts"
|
||
)
|
||
|
||
// TokenSink 是 Token 流回流出口(由 NATS bus 实现)。
|
||
type TokenSink interface {
|
||
PublishToken(taskID string, token []byte) error
|
||
CompleteStream(taskID string) error
|
||
}
|
||
|
||
// ToolCaller 经 NATS 调起第 5 层 MCP 工具(由 NATS bus 实现)。
|
||
type ToolCaller interface {
|
||
CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error)
|
||
}
|
||
|
||
// StatusSink 回写任务生命周期状态(由 NATS bus 实现;可为 nil → 不回写)。
|
||
type StatusSink interface {
|
||
PublishTaskStatus(taskID, status, detail string) error
|
||
}
|
||
|
||
// ApprovalWaiter 阻塞等待审批节点的人工决定(由 NATS bus 实现;可为 nil → 审批节点自动放行)。
|
||
type ApprovalWaiter interface {
|
||
WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error)
|
||
}
|
||
|
||
// EvalSink 回写自动化评测结果供网关落库(由 NATS bus 实现;可为 nil → 仅打日志不落库)。
|
||
type EvalSink interface {
|
||
PublishEval(ev *contract.EvalEvent) error
|
||
}
|
||
|
||
// UsageSink 回写任务 token 用量供网关累计/计费(由 NATS bus 实现;可为 nil → 不回写)。
|
||
type UsageSink interface {
|
||
PublishUsage(ev *contract.UsageEvent) error
|
||
}
|
||
|
||
// errBudget 是单任务 token 预算触顶时的哨兵错误:任务以 failed 收尾并附明确原因(防失控成本)。
|
||
var errBudget = errors.New("token 预算超限,已中止")
|
||
|
||
// errRejected 是审批节点拒绝(或超时)时图执行返回的哨兵错误:它是合法终态而非故障,
|
||
// Handle 据此判 rejected 并优雅收尾(不计熔断失败)。
|
||
var errRejected = errors.New("approval rejected")
|
||
|
||
// errInterrupted 是 HITL 审批中断(compose checkpoint)时图执行返回的哨兵错误:任务停在
|
||
// waiting、checkpoint 已落、goroutine 即可释放;非故障非终态,等人工决定到达后 resume 续跑。
|
||
var errInterrupted = errors.New("approval interrupted (checkpointed)")
|
||
|
||
// LLM 是编排所需的语言模型能力(生产由 *llm.Pool 实现)。抽成接口便于测试注入假模型。
|
||
type LLM interface {
|
||
Ready() bool
|
||
ChatStream(ctx context.Context, msgs []llm.ChatMessage, onToken func(string), onReasoning func(string)) error
|
||
StreamText(ctx context.Context, text string, onToken func([]byte)) error
|
||
Chat(ctx context.Context, msgs []llm.ChatMessage) (string, error)
|
||
// ToolCallingModel 返回支持函数调用的模型(ReAct agent 用);不支持则返回 nil。
|
||
ToolCallingModel() model.ToolCallingChatModel
|
||
// ChatModel 返回 Eino ChatModel 组件(compose.Graph 编排用);未就绪则 nil。
|
||
ChatModel() model.BaseChatModel
|
||
}
|
||
|
||
// 工具调用超时;超时即降级(不带工具上下文继续推理)。
|
||
const toolCallTimeout = 3 * time.Second
|
||
|
||
// taskExecTimeout 是单个任务整体执行上限;超时即判 timeout(状态机),避免无限期"运行中"。
|
||
// 含 HITL 审批等待预算(approvalTimeout)+ 常规图执行;须 < bus 消费者 AckWait(15min) 以免重投。
|
||
const taskExecTimeout = 10 * time.Minute
|
||
|
||
// approvalTimeout 是单个审批节点等待人工决定的上限;超时安全默认拒绝(fail-safe)。
|
||
const approvalTimeout = 5 * time.Minute
|
||
|
||
// specialistTimeout 是多智能体单个专家派发的执行上限;超时即作为"观察"跳过该专家
|
||
// (不中断整个协调),避免单专家卡死拖垮 lead 无限等待。专家可能多轮 react+工具调用,故给较宽。
|
||
const specialistTimeout = 3 * time.Minute
|
||
|
||
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
|
||
type Orchestrator struct {
|
||
pool LLM
|
||
breaker *harness.CircuitBreaker
|
||
eval *harness.Evaluator
|
||
sink TokenSink
|
||
tools ToolCaller
|
||
exec ExecSink
|
||
status StatusSink // 任务生命周期状态回写(可为 nil)
|
||
approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行)
|
||
evalSink EvalSink // 评测结果回写落库(可为 nil → 仅打日志)
|
||
guard *harness.Classifier // 输入护栏 Tier2:对网关标记的灰区任务做 LLM 裁决(可为 nil → 不做)
|
||
usageSink UsageSink // token 用量回写(可为 nil → 不回写)
|
||
checkpoints CheckpointKV // HITL 持久化中断的 checkpoint 后端(可为 nil → 审批走阻塞模型)
|
||
|
||
turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享)
|
||
turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate
|
||
}
|
||
|
||
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
|
||
// exec 为执行可视化事件出口(可为 nil);eval 为自动化评测(可为 nil);status 为状态回写(可为 nil);
|
||
// approval 为 HITL 审批等待(可为 nil);evalSink 为评测结果落库出口(可为 nil)。
|
||
func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink, status StatusSink, approval ApprovalWaiter, evalSink EvalSink) (*Orchestrator, error) {
|
||
return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil
|
||
}
|
||
|
||
// SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。
|
||
func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c }
|
||
|
||
// SetUsageSink 注入 token 用量回写出口(可选;不注入则不上报用量)。
|
||
func (o *Orchestrator) SetUsageSink(s UsageSink) { o.usageSink = s }
|
||
|
||
// SetCheckpoints 注入 HITL 持久化中断的 checkpoint 后端(可选)。注入后审批节点改走
|
||
// compose.Interrupt 中断模型(落 checkpoint、释放 goroutine、抗重启);不注入则维持阻塞模型。
|
||
func (o *Orchestrator) SetCheckpoints(kv CheckpointKV) { o.checkpoints = kv }
|
||
|
||
// taskBudget 取本任务的 token 预算上限:优先 Meta(网关按用户/套餐下发),否则 env TASK_TOKEN_BUDGET(默认 20 万)。
|
||
func (o *Orchestrator) taskBudget(t *contract.Task) int {
|
||
switch n := t.Meta[contract.MetaTokenBudget].(type) {
|
||
case float64:
|
||
if n > 0 {
|
||
return int(n)
|
||
}
|
||
case int:
|
||
if n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
return envInt("TASK_TOKEN_BUDGET", 200000)
|
||
}
|
||
|
||
// emitUsage 任务收尾回写本轮 token 用量(用量为 0 或无出口则跳过)。
|
||
func (o *Orchestrator) emitUsage(t *contract.Task, b *harness.Budget) {
|
||
if o.usageSink == nil {
|
||
return
|
||
}
|
||
p, c, total := b.Snapshot()
|
||
if total == 0 {
|
||
return
|
||
}
|
||
uid, _ := t.Meta[contract.MetaUserID].(string)
|
||
tid, _ := t.Meta[contract.MetaTenantID].(string)
|
||
if err := o.usageSink.PublishUsage(&contract.UsageEvent{
|
||
TaskID: t.ID, UserID: uid, TenantID: tid, PromptTok: p, CompTok: c, TotalTok: total,
|
||
Exceeded: b.Exceeded(), TS: time.Now().UnixMilli(),
|
||
}); err != nil {
|
||
log.Printf("[usage] 回写用量失败 task=%s: %v", t.ID, err)
|
||
}
|
||
}
|
||
|
||
// envInt 读正整数环境变量,缺省回退 def。
|
||
func envInt(key string, def int) int {
|
||
if v := os.Getenv(key); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
return n
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
// setStatus 回写一次任务状态流转(status 为 nil 时静默跳过)。
|
||
func (o *Orchestrator) setStatus(taskID, status, detail string) {
|
||
if o.status == nil {
|
||
return
|
||
}
|
||
if err := o.status.PublishTaskStatus(taskID, status, detail); err != nil {
|
||
log.Printf("[eino] 回写任务状态 %s=%s 失败: %v", taskID, status, err)
|
||
}
|
||
}
|
||
|
||
// finishStatus 据收尾错误把任务置为 done / timeout / failed。
|
||
func (o *Orchestrator) finishStatus(taskID string, err error) {
|
||
switch {
|
||
case err == nil:
|
||
o.setStatus(taskID, contract.TaskDone, "")
|
||
case errors.Is(err, context.DeadlineExceeded):
|
||
o.setStatus(taskID, contract.TaskTimeout, "执行超时")
|
||
default:
|
||
o.setStatus(taskID, contract.TaskFailed, truncate(err.Error(), 200))
|
||
}
|
||
}
|
||
|
||
// Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。
|
||
func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||
// 护栏:丢弃空任务(无 id),避免误投/历史脏数据被当真任务处理并触发状态回写放大。
|
||
if t.ID == "" {
|
||
log.Printf("[eino] 跳过空任务(无 id)")
|
||
return nil
|
||
}
|
||
|
||
// 链路根(dispatcher 侧):续上 gateway 经 NATS 传来的 trace,覆盖整个图执行。
|
||
ctx, span := otelx.Tracer().Start(ctx, "task.execute",
|
||
trace.WithAttributes(attribute.String("sundynix.task_id", t.ID)))
|
||
defer span.End()
|
||
|
||
tr := o.tracer(t.ID)
|
||
defer tr.done()
|
||
|
||
// 熔断开启:快速拒绝,但要让客户端解阻(回流提示 + 收尾流),不静默丢弃。
|
||
if !o.breaker.Allow() {
|
||
log.Printf("[eino] 熔断开启,拒绝任务 %s", t.ID)
|
||
tr.info("task", "system", "服务熔断", "后端连续失败,暂时拒绝新任务,请稍后重试")
|
||
_ = o.sink.PublishToken(t.ID, []byte("⚠️ 服务繁忙(已触发熔断保护),请稍后重试。"))
|
||
_ = o.sink.CompleteStream(t.ID)
|
||
o.setStatus(t.ID, contract.TaskFailed, "服务熔断")
|
||
return nil
|
||
}
|
||
|
||
// 输入护栏 Tier2:仅对网关 Tier1 标记的「灰区」任务做 LLM 越狱裁决(明确干净/恶意的已在网关处理,
|
||
// 不付 LLM 成本)。命中即拒绝执行(合法终态,非故障,不计熔断)。
|
||
if o.guard != nil {
|
||
if safety, _ := t.Meta[contract.MetaSafetyCheck].(bool); safety {
|
||
query := dsl.Compile(t.Graph).Query
|
||
gctx, gcancel := context.WithTimeout(ctx, 10*time.Second)
|
||
block, sev, reason := o.guard.Classify(gctx, query)
|
||
gcancel()
|
||
if block {
|
||
slog.WarnContext(ctx, "input guardrail blocked", "task_id", t.ID, "severity", sev, "reason", reason)
|
||
tr.info("guardrail", "system", "输入护栏拦截", reason)
|
||
_ = o.sink.PublishToken(t.ID, []byte("⚠️ 输入被安全护栏拦截:"+reason))
|
||
_ = o.sink.CompleteStream(t.ID)
|
||
o.breaker.Report(true) // 安全拦截是策略决策,非后端故障
|
||
o.setStatus(t.ID, contract.TaskRejected, truncate("输入护栏:"+reason, 200))
|
||
return nil
|
||
}
|
||
if sev > 0 {
|
||
slog.InfoContext(ctx, "input guardrail suspect passed", "task_id", t.ID, "severity", sev)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 任务状态机:进入执行 → running;整体加超时上限,超时判 timeout(杜绝无限期"运行中")。
|
||
o.setStatus(t.ID, contract.TaskRunning, "")
|
||
tctx, cancel := context.WithTimeout(ctx, taskExecTimeout)
|
||
defer cancel()
|
||
|
||
// 成本护栏:单任务 token 预算挂到 ctx,沿图执行各 LLM 节点计量+封顶;收尾回写用量(计费/日预算)。
|
||
budget := harness.NewBudget(o.taskBudget(t))
|
||
tctx = harness.WithBudget(tctx, budget)
|
||
defer o.emitUsage(t, budget)
|
||
|
||
// 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。
|
||
if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport {
|
||
err := o.handleReport(tctx, t, tr)
|
||
o.finishStatus(t.ID, err)
|
||
return err
|
||
}
|
||
// ctx 携带 task.execute span → 这些任务生命周期日志自动带 trace_id,可与 Jaeger 链路互跳。
|
||
slog.InfoContext(ctx, "task received", "task_id", t.ID, "graph_bytes", len(t.Graph))
|
||
tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,按图执行", len(t.Graph)))
|
||
|
||
// 按 DSL 图执行:编译为 Eino compose.Graph 并跑(唯一编排引擎);agent 节点流式回流 token。
|
||
answer, refs, err := o.executeGraph(tctx, t, tr)
|
||
if errors.Is(err, errInterrupted) {
|
||
// HITL 持久化中断:checkpoint 已落、任务停在 waiting(状态在审批节点内已置)。
|
||
// 释放 goroutine 但不收尾——不 CompleteStream、不评测、不判 done、不计熔断失败;
|
||
// token 流与 exec 流都保持打开(suspended),resume 时续录,否则复盘里审批节点永停在等待中。
|
||
tr.suspended = true
|
||
slog.InfoContext(ctx, "task interrupted for approval (checkpointed)", "task_id", t.ID)
|
||
o.breaker.Report(true) // 中断是正常暂停,非后端故障
|
||
return nil
|
||
}
|
||
if errors.Is(err, errRejected) {
|
||
// HITL 拒绝:合法终态,非故障。收尾流 + 置 rejected,不计熔断、不重投。
|
||
slog.InfoContext(ctx, "task rejected by approval", "task_id", t.ID)
|
||
if answer != "" {
|
||
_ = o.sink.PublishToken(t.ID, []byte(answer))
|
||
}
|
||
_ = o.sink.CompleteStream(t.ID)
|
||
o.breaker.Report(true) // 拒绝是人为决策,不算后端失败
|
||
o.setStatus(t.ID, contract.TaskRejected, truncate(answer, 120))
|
||
return nil
|
||
}
|
||
if errors.Is(err, errBudget) {
|
||
// token 预算触顶:策略性中止,非后端故障。收尾流 + 置 failed(附明确原因),不计熔断、不重投。
|
||
_, _, total := budget.Snapshot()
|
||
slog.WarnContext(ctx, "task aborted: token budget exceeded", "task_id", t.ID, "tokens", total)
|
||
if answer != "" {
|
||
_ = o.sink.PublishToken(t.ID, []byte(answer))
|
||
}
|
||
_ = o.sink.PublishToken(t.ID, []byte("\n\n⚠️ 已达单任务 token 预算上限,自动中止。"))
|
||
_ = o.sink.CompleteStream(t.ID)
|
||
o.breaker.Report(true)
|
||
o.setStatus(t.ID, contract.TaskFailed, fmt.Sprintf("token 预算超限(已用约 %d)", total))
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
span.RecordError(err)
|
||
span.SetStatus(codes.Error, err.Error())
|
||
slog.ErrorContext(ctx, "task graph error", "task_id", t.ID, "err", err.Error())
|
||
_ = o.sink.CompleteStream(t.ID)
|
||
o.breaker.Report(false)
|
||
o.finishStatus(t.ID, err)
|
||
return err
|
||
}
|
||
|
||
if cerr := o.sink.CompleteStream(t.ID); cerr != nil {
|
||
log.Printf("[eino] complete stream failed: %v", cerr)
|
||
}
|
||
slog.InfoContext(ctx, "task done", "task_id", t.ID, "answer_runes", len([]rune(answer)))
|
||
o.breaker.Report(true)
|
||
o.finishStatus(t.ID, nil)
|
||
|
||
// 写回阶段(离热路径,单 goroutine 串起评测与落历史):
|
||
// 先评测(低分则自动纠偏重生成、取更优者),再把最终采纳的答案落会话历史 —— 保证
|
||
// 多轮上下文用的是纠偏后的好答案,而非被判低分的初版。
|
||
go func() {
|
||
query := dsl.Compile(t.Graph).Query
|
||
final := o.evaluate(t, query, answer, refs)
|
||
o.memorize(t, final)
|
||
}()
|
||
return nil
|
||
}
|
||
|
||
// maxRefineRounds 是低分自动纠偏的最大重生成轮数(防成本失控/死循环)。
|
||
// 每轮 = 1 次重写 + 1 次重评;poor 本就稀少,1 轮足以覆盖大多数偶发劣化。
|
||
const maxRefineRounds = 1
|
||
|
||
// evaluate 异步对一次输出做自动化评测并记录评分(off 热路径,不影响响应)。
|
||
// 低分(poor)时触发"评语驱动"的自动纠偏:重生成更优答案、重评、取更优者(恒温器闭环)。
|
||
// sources 为本轮检索来源:非空时额外评忠实度(幻觉检测)。
|
||
// 返回最终采纳的答案(纠偏成功则为修订版,否则为原文)——供调用方落会话历史。
|
||
func (o *Orchestrator) evaluate(t *contract.Task, input, output string, sources []string) string {
|
||
if o.eval == nil {
|
||
return output
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||
defer cancel()
|
||
r := o.eval.Score(ctx, input, output, sources)
|
||
level := evalLevel(r)
|
||
log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2f / 忠实 %.2f,来源 %d)level=%s flags=%v %s",
|
||
t.ID, r.Overall, r.Rule, r.LLM, r.Faithful, len(sources), level, r.Flags, r.Reason)
|
||
|
||
// 低分自动纠偏:把"问题 + 初版回答 + 评审短板"交给模型重写,重评后仅采纳更优者(不退步)。
|
||
corrected := false
|
||
for round := 0; level == contract.EvalPoor && round < maxRefineRounds && o.canRefine(); round++ {
|
||
newOut, newR, ok := o.refine(ctx, t, input, output, sources, r)
|
||
if !ok {
|
||
break // 重写失败或未改好:保留当前最优,停止
|
||
}
|
||
prevOverall := r.Overall
|
||
output, r, level, corrected = newOut, newR, evalLevel(newR), true
|
||
log.Printf("[eval] task %s 自动纠偏第 %d 轮采纳:综合 %.2f→%.2f level=%s", t.ID, round+1, prevOverall, r.Overall, level)
|
||
}
|
||
|
||
if level == contract.EvalPoor { // 纠偏后仍低质量:告警(可观测 + 后续可挂报警)
|
||
slog.Warn("eval poor quality", "task_id", t.ID, "overall", r.Overall, "faithful", r.Faithful, "flags", r.Flags, "corrected", corrected)
|
||
}
|
||
// 闭环:评测结果(纠偏后终值)落库(供 UI 查询 / 质量趋势 / 门控)。
|
||
if o.evalSink != nil {
|
||
if err := o.evalSink.PublishEval(&contract.EvalEvent{
|
||
TaskID: t.ID, Overall: r.Overall, Rule: r.Rule, LLM: r.LLM, Faithful: r.Faithful,
|
||
Level: level, Flags: r.Flags, Reason: r.Reason, Sources: len(sources), Corrected: corrected,
|
||
TS: time.Now().UnixMilli(),
|
||
}); err != nil {
|
||
log.Printf("[eval] 回写评测结果失败 task=%s: %v", t.ID, err)
|
||
}
|
||
}
|
||
return output
|
||
}
|
||
|
||
// canRefine 报告是否具备自动纠偏条件:模型就绪且熔断未开(重写要再打一次模型,避雪上加霜)。
|
||
func (o *Orchestrator) canRefine() bool {
|
||
return o.pool != nil && o.pool.Ready() && (o.breaker == nil || o.breaker.Allow())
|
||
}
|
||
|
||
// refine 对低分输出做一次"评语驱动"的重生成:把原问题 + 初版回答 + 评测短板(flags/评语)
|
||
// 交给模型重写,再重新评分。仅当新分严格更高时采纳(ok=true);否则保留原文(不退步)。
|
||
// 有检索来源时把来源一并喂回,要求严格基于来源作答(同时压低幻觉)。
|
||
func (o *Orchestrator) refine(ctx context.Context, t *contract.Task, input, output string, sources []string, prev harness.Result) (string, harness.Result, bool) {
|
||
tr := o.tracer(t.ID)
|
||
end := tr.span("refine", "system", "低分自动纠偏")
|
||
|
||
critique := strings.Join(prev.Flags, ";")
|
||
if prev.Reason != "" {
|
||
if critique != "" {
|
||
critique += ";"
|
||
}
|
||
critique += "评语:" + prev.Reason
|
||
}
|
||
if critique == "" {
|
||
critique = "整体质量不足(相关性/准确性/完整性欠佳)"
|
||
}
|
||
sys := prompts.Get(prompts.EvalRefine)
|
||
srcBlock := ""
|
||
if len(sources) > 0 {
|
||
srcBlock = "\n\n【可参考的检索资料(严格基于它作答,不要编造资料外的内容)】\n" +
|
||
truncate(strings.Join(sources, "\n---\n"), 3000)
|
||
}
|
||
user := fmt.Sprintf("【用户问题】%s\n\n【初版回答(质量不足)】%s\n\n【评审指出的问题】%s%s\n\n请直接输出修订后的回答正文,不要解释你做了哪些修改。",
|
||
truncate(input, 1000), truncate(output, 2000), critique, srcBlock)
|
||
|
||
revised, err := o.pool.Chat(ctx, []llm.ChatMessage{
|
||
{Role: "system", Content: sys},
|
||
{Role: "user", Content: user},
|
||
})
|
||
if err != nil || strings.TrimSpace(revised) == "" {
|
||
end("重写失败", errOrEmpty(err))
|
||
return output, prev, false
|
||
}
|
||
newR := o.eval.Score(ctx, input, revised, sources)
|
||
if newR.Overall <= prev.Overall {
|
||
end(fmt.Sprintf("重写未提升(%.2f≤%.2f),保留原版", newR.Overall, prev.Overall), nil)
|
||
return output, prev, false
|
||
}
|
||
end(fmt.Sprintf("已采纳修订:综合 %.2f→%.2f", prev.Overall, newR.Overall), nil)
|
||
return revised, newR, true
|
||
}
|
||
|
||
// errOrEmpty 把空输出也表达成一个错误,供 exec error 事件着色。
|
||
func errOrEmpty(err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return errors.New("空输出")
|
||
}
|
||
|
||
// evalLevel 据综合分 + 忠实度把评测分级(闭环门控/告警用)。
|
||
func evalLevel(r harness.Result) string {
|
||
switch {
|
||
case r.Overall < 0.5:
|
||
return contract.EvalPoor
|
||
case r.Overall < 0.75 || (r.Faithful > 0 && r.Faithful < 0.6):
|
||
return contract.EvalWarn
|
||
default:
|
||
return contract.EvalOK
|
||
}
|
||
}
|
||
|
||
// fetchMemory 经 MCP memory_get 工具召回用户常驻画像。
|
||
// 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。
|
||
func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string {
|
||
if o.tools == nil || userID == "" {
|
||
return ""
|
||
}
|
||
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
||
defer cancel()
|
||
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_get"), &contract.ToolCall{
|
||
Tool: "memory_get",
|
||
Args: map[string]any{"user_id": userID},
|
||
})
|
||
if err != nil {
|
||
log.Printf("[eino] memory_get unavailable for %s, degrade: %v", userID, err)
|
||
return ""
|
||
}
|
||
if !res.OK {
|
||
log.Printf("[eino] memory_get error for %s: %s", userID, res.Error)
|
||
return ""
|
||
}
|
||
log.Printf("[eino] memory_get ok for %s: %s", userID, res.Content)
|
||
return res.Content
|
||
}
|
||
|
||
// fetchHistory 经 MCP history_get 工具召回会话短期多轮历史,转为 Eino 消息。
|
||
// 工具不可用/无 session 时返回空,降级为无历史(不阻断主流程)。
|
||
func (o *Orchestrator) fetchHistory(ctx context.Context, sessionID string) []*schema.Message {
|
||
if o.tools == nil || sessionID == "" {
|
||
return nil
|
||
}
|
||
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
|
||
defer cancel()
|
||
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_get"), &contract.ToolCall{
|
||
Tool: "history_get",
|
||
Args: map[string]any{"session_id": sessionID},
|
||
})
|
||
if err != nil || res == nil || !res.OK || res.Content == "" {
|
||
return nil
|
||
}
|
||
var turns []struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
if json.Unmarshal([]byte(res.Content), &turns) != nil {
|
||
return nil
|
||
}
|
||
msgs := make([]*schema.Message, 0, len(turns))
|
||
for _, tn := range turns {
|
||
if tn.Role == "assistant" {
|
||
msgs = append(msgs, schema.AssistantMessage(tn.Content, nil))
|
||
} else {
|
||
msgs = append(msgs, schema.UserMessage(tn.Content))
|
||
}
|
||
}
|
||
if len(msgs) > 0 {
|
||
log.Printf("[eino] history_get ok for %s: %d 条历史", sessionID, len(msgs))
|
||
}
|
||
return msgs
|
||
}
|
||
|
||
// consolidateEveryTurns 控制记忆对账的攒批节奏:每 N 轮 consolidate 一次(不逐轮,省成本)。
|
||
const consolidateEveryTurns = 3
|
||
|
||
// memorize 写回阶段(异步、离热路径):落短期历史;每 N 轮做一次记忆对账(consolidate)。
|
||
func (o *Orchestrator) memorize(t *contract.Task, answer string) {
|
||
// 空答复(LLM 失败/降级/拒绝)不落历史:空 assistant 消息会被 LLM API 拒绝(400),
|
||
// 一旦写入会毒化该会话后续所有请求。失败的一轮干脆不留痕。
|
||
if strings.TrimSpace(answer) == "" {
|
||
return
|
||
}
|
||
uid, _ := t.Meta[contract.MetaUserID].(string)
|
||
sid, _ := t.Meta[contract.MetaSessionID].(string)
|
||
if sid != "" && o.tools != nil {
|
||
o.appendHistory(sid, "user", dsl.Compile(t.Graph).Query) // 落真实用户输入,而非 DSL 原文
|
||
o.appendHistory(sid, "assistant", answer)
|
||
log.Printf("[eino] (writeback) task %s 已落会话历史 session=%s", t.ID, sid)
|
||
}
|
||
if uid == "" || sid == "" || o.tools == nil {
|
||
return
|
||
}
|
||
// 攒批:累计轮次,每 N 轮才把近期对话与已有画像交给 LLM 对账一次。
|
||
o.turnMu.Lock()
|
||
if o.turns == nil {
|
||
o.turns = map[string]int{}
|
||
}
|
||
o.turns[sid]++
|
||
n := o.turns[sid]
|
||
o.turnMu.Unlock()
|
||
if n%consolidateEveryTurns == 0 {
|
||
ctx := context.Background()
|
||
o.consolidateMemory(ctx, uid, o.fetchHistory(ctx, sid))
|
||
}
|
||
}
|
||
|
||
func (o *Orchestrator) appendHistory(sessionID, role, content string) {
|
||
cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout)
|
||
defer cancel()
|
||
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_append"), &contract.ToolCall{
|
||
Tool: "history_append",
|
||
Args: map[string]any{"session_id": sessionID, "role": role, "content": content},
|
||
}); err != nil {
|
||
log.Printf("[eino] history_append failed: %v", err)
|
||
}
|
||
}
|