Files
sundynix-agentix/sundynix-dispatcher/internal/eino/orchestrator.go
T
Blizzard d032c198c8 feat: SaaS P2 计量·增量1 —— 用量按租户落持久明细 + 积分/成本折算
链路打通 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>
2026-07-07 10:54:29 +08:00

552 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,来源 %dlevel=%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)
}
}