在过去的几个模块中,我们如同打造一辆超级跑车般,为 go-tiny-claw 组装了强大的 V8 引擎(Main Loop)、防抱死刹车(Safety Middleware)、甚至是能自动寻路的“副驾驶”(Subagent)。但是,如果这辆跑车没有“仪表盘(Dashboard)”,你敢把它开上真实的赛道吗?
想象一下,你把 go-tiny-claw 部署到了公司的生产环境中,团队的 10 个开发人员每天都在飞书里唤醒它去做代码 Review 和 Bug 排查。月底结算时,老板拿着一张高达几万元的 API 账单质问你:为什么这个月的大模型费用这么高?到底是哪一个任务、调了哪个工具消耗了最多的 Token?Agent 每次回复都要等 30 秒,到底是网络慢、还是它在本地执行 go test 慢、还是大模型推理慢?
如果你无法回答这些问题,你的 Agent 依然只能是一个“玩具”,老板不会批准你将其投入到日常生产,也无法成为企业级的数字资产。
我们将通过极简的代码,在 Harness 层(而非业务层)拦截大模型的返回包,精确记录 Token 消耗、金钱成本和执行耗时。
成本追踪
成本由哪些构成
在调用大模型 API 时,成本主要由两部分构成:
Prompt Tokens(输入 Token):这是大模型阅读系统提示词、对话历史和文件内容的成本。在 go-tiny-claw 中,由于上下文是在不断累加的,输入 Token 会随着对话轮数呈现出近似 O(n²) 的增长趋势。
Completion Tokens(输出 Token):这是大模型生成回答、思考过程(Thinking Trace)和工具调用参数(JSON)的成本。通常比输入 Token 贵 3-5 倍。
除了金钱成本,时间成本也是决定 Agent 体验的关键。
一个 Turn 的耗时 = 大模型推理耗时 + 工具在本地的物理执行耗时(如 go build)。
代码实战:构建 Cost Tracker 中间件
接下来,我们将用 Go 语言将这个优雅的架构变现。
第 1 步:扩展基础数据结构
大模型 API 会在返回结果中附带 Token 消耗的元数据(Metadata)。我们需要在 schema 中找个地方接住它们。打开 internal/schema/message.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package schema
import "encoding/json"
type Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` }
type Message struct { Role Role `json:"role"` Content string `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
Usage *Usage `json:"usage,omitempty"` }
|
接着,我们需要让 Session 能够记住自己“这辈子”一共花了多少钱。打开 internal/engine/session.go,修改 Session 结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package engine
import ( )
type Session struct { ID string CreatedAt time.Time UpdatedAt time.Time
TotalPromptTokens int TotalCompletionTokens int TotalCostCNY float64
history []schema.Message mu sync.RWMutex }
func (s *Session) RecordUsage(prompt int, completion int, cost float64) { s.mu.Lock() defer s.mu.Unlock() s.TotalPromptTokens += prompt s.TotalCompletionTokens += completion s.TotalCostCNY += cost }
|
第 2 步:在 Provider 适配层提取 Token
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| package provider
import ( )
func (p *OpenAIProvider) Generate(ctx context.Context, msgs []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, error) {
resp, err := p.client.Chat.Completions.New(ctx, params) if err != nil { return nil, fmt.Errorf("OpenAI/Zhipu API 请求失败: %w", err) }
choice := resp.Choices[0].Message resultMsg := &schema.Message{ Role: schema.RoleAssistant, Content: choice.Content, }
if resp.Usage.PromptTokens > 0 || resp.Usage.CompletionTokens > 0 { resultMsg.Usage = &schema.Usage{ PromptTokens: int(resp.Usage.PromptTokens), CompletionTokens: int(resp.Usage.CompletionTokens), } }
return resultMsg, nil }
|
第 3 步:编写优雅的 Cost Tracker 装饰器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| package observability
import ( "context" "log" "time"
"github.com/yourname/go-tiny-claw/internal/provider" "github.com/yourname/go-tiny-claw/internal/schema" ctxpkg "github.com/yourname/go-tiny-claw/internal/context" )
var PricingModel = map[string]struct { InputPrice float64 OutputPrice float64 }{ "glm-4.5-air": {InputPrice: 0.15, OutputPrice: 0.15}, }
type CostTracker struct { nextProvider provider.LLMProvider modelName string session *ctxpkg.Session }
func NewCostTracker(next provider.LLMProvider, modelName string, session *ctxpkg.Session) *CostTracker { return &CostTracker{ nextProvider: next, modelName: modelName, session: session, } }
func (t *CostTracker) Generate(ctx context.Context, msgs []schema.Message, availableTools []schema.ToolDefinition) (*schema.Message, error) {
startTime := time.Now()
respMsg, err := t.nextProvider.Generate(ctx, msgs, availableTools)
latency := time.Since(startTime)
if err != nil { log.Printf("[Tracker] ❌ API 调用失败,耗时: %v\n", latency) return respMsg, err }
if respMsg.Usage != nil { promptTokens := respMsg.Usage.PromptTokens completionTokens := respMsg.Usage.CompletionTokens
var cost float64 if price, exists := PricingModel[t.modelName]; exists { cost = (float64(promptTokens)*price.InputPrice + float64(completionTokens)*price.OutputPrice) / 1000000.0 }
log.Printf("[Tracker] 📊 API 调用完成 | 耗时: %v | 输入: %d tk | 输出: %d tk | 花费: ¥%.6f\n", latency, promptTokens, completionTokens, cost)
if t.session != nil { t.session.RecordUsage(promptTokens, completionTokens, cost) log.Printf("[Tracker] 💰 当前会话 (%s) 累计花费: ¥%.6f\n", t.session.ID, t.session.TotalCostCNY) } } else { log.Printf("[Tracker] ⚠️ API 调用完成,但未返回 Usage 数据 | 耗时: %v\n", latency) }
return respMsg, nil }
|
第 4 步:在 Main 函数中像组装乐高一样串联它们
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| package main
import ( "context" "log" "os"
ctxpkg "github.com/yourname/go-tiny-claw/internal/context" "github.com/yourname/go-tiny-claw/internal/engine" "github.com/yourname/go-tiny-claw/internal/observability" "github.com/yourname/go-tiny-claw/internal/provider" "github.com/yourname/go-tiny-claw/internal/schema" "github.com/yourname/go-tiny-claw/internal/tools" )
func main() { if os.Getenv("ZHIPU_API_KEY") == "" { log.Fatal("请先导出 ZHIPU_API_KEY 环境变量") }
workDir, _ := os.Getwd() modelName := "glm-4.5-air"
realProvider := provider.NewZhipuOpenAIProvider(modelName)
sessionID := "test_observability_001" sess := ctxpkg.GlobalSessionMgr.GetOrCreate(sessionID, workDir)
trackedProvider := observability.NewCostTracker(realProvider, modelName, sess)
registry := tools.NewRegistry() registry.Register(tools.NewBashTool(workDir))
eng := engine.NewAgentEngine(trackedProvider, registry, false, false) reporter := engine.NewTerminalReporter()
prompt := `请用 bash 帮我用 date 命令查一下现在的时间。`
log.Println("\n>>> 🚀 启动带仪表盘的可观测性测试...") sess.Append(schema.Message{Role: schema.RoleUser, Content: prompt})
err := eng.Run(context.Background(), sess, reporter) if err != nil { log.Fatalf("引擎运行崩溃: %v", err) }
log.Printf("\n================ 财务报表 ================\n") log.Printf("会话 ID: %s\n", sess.ID) log.Printf("总消耗 Input Tokens: %d\n", sess.TotalPromptTokens) log.Printf("总消耗 Output Tokens: %d\n", sess.TotalCompletionTokens) log.Printf("总计费用 (CNY): ¥%.6f\n", sess.TotalCostCNY) log.Printf("==========================================\n") }
|
总结
算明经济账是落地的关键:在驾驭工程中,衡量一个 Agent 是否优秀,除了看它能不能把代码跑通,更要看它的 Token 效率。如果不把成本监控落到代码实处,就无法优化 System Prompt 的长度,也无从判断上下文压缩是否真的起到了省钱的作用。
装饰器模式的优雅应用:为了保持核心引擎(Main Loop)的纯粹性,我们没有在里面混入任何一行记录时间或计费的代码。我们通过实现一个包装了真实 LLMProvider 的 CostTracker,实现了功能的无缝外挂(运用了类似 AOP 面向切面编程的思想)。
长期价值的沉淀:通过将会话总账单挂载到 Session 对象上
Tracing机制
大模型本身是一个不可控的“黑盒(Black Box)”。如果在驾驭工程(Harness Engineering)中,我们不能提供透视这个黑盒的“X 光机”,一旦 Agent 发生智障行为,我们将陷入无法调试的境地。
我们将补齐可观测性体系(Observability)中最具技术含量的一环:链路追踪(Tracing)。我们将像微服务架构那样,用纯 Go 语言实现一套轻量级的上下文级联追踪机制,将 Agent 的每一次“思考 - 行动”完整固化为可供回放的 JSON 决策树。
Agent 链路追踪的本质是树(Tree)
在 Agent 的驾驭工程中,Tracing 的理念是完全一致的。只不过,我们的追踪对象从网络节点变成了智能体的决策层级。一个完整的 Agent 运行周期,天然具备一棵极度工整的树状结构:
Root Span(根跨度):代表一次完整的 Run 任务。
Child Spans(子跨度):代表 ReAct 循环中的每一个 Turn。
Leaf Spans(叶子节点):代表每一个 Turn 内部的细分操作,例如 Generate(LLM 调用)、Execute(工具执行)、Compaction(内存压缩)。
代码实战
我们将所有的追踪代码收敛在 internal/observability/trace.go 中,并在 engine 和 tools 层进行埋点。
第 1 步:实现 Trace 数据结构与上下文传递
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| package observability
import ( "context" "encoding/json" "os" "path/filepath" "sync" "time" )
type traceKey struct{}
type Span struct { Name string `json:"name"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` DurationMs int64 `json:"duration_ms"` Attributes map[string]interface{} `json:"attributes,omitempty"` Children []*Span `json:"children,omitempty"`
mu sync.Mutex }
func StartSpan(ctx context.Context, name string) (context.Context, *Span) { span := &Span{ Name: name, StartTime: time.Now(), Attributes: make(map[string]interface{}), }
if parent, ok := ctx.Value(traceKey{}).(*Span); ok { parent.mu.Lock() parent.Children = append(parent.Children, span) parent.mu.Unlock() }
newCtx := context.WithValue(ctx, traceKey{}, span) return newCtx, span }
func (s *Span) EndSpan() { s.EndTime = time.Now() s.DurationMs = s.EndTime.Sub(s.StartTime).Milliseconds() }
func (s *Span) AddAttribute(key string, value interface{}) { s.mu.Lock() defer s.mu.Unlock() s.Attributes[key] = value }
func ExportTraceToFile(rootSpan *Span, workDir string, sessionID string) error { traceDir := filepath.Join(workDir, ".claw", "traces") os.MkdirAll(traceDir, 0755)
filename := filepath.Join(traceDir, fmt.Sprintf("trace_%s_%d.json", sessionID, time.Now().Unix()))
data, err := json.MarshalIndent(rootSpan, "", " ") if err != nil { return err }
return os.WriteFile(filename, data, 0644) }
|
这段代码完美利用了 Go 语言 context.WithValue 的特性。我们通过每次进入新函数时调用 ctx, span := StartSpan(ctx, “Name”),在不知不觉中构建出了一棵完整的调用树,而且完全不用担心并发安全问题。
第 2 步:在核心代码中埋点 (Instrumentation)
有了工具,接下来我们要在 Harness 的关键生命周期节点进行“埋点”。埋点在驾驭工程中是一项艺术:埋得太多,性能下降、日志噪音大;埋得太少,关键信息丢失。
在 Main Loop 中埋点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
| package engine
import ( "context" "github.com/yourname/go-tiny-claw/internal/observability" )
func (e *AgentEngine) Run(ctx context.Context, session *Session, reporter Reporter) error { log.Printf("[Engine] 唤醒会话 [%s],锁定工作区: %s (PlanMode: %v)\n", session.ID, session.WorkDir, e.PlanMode)
ctx, rootSpan := observability.StartSpan(ctx, "Agent.Run") rootSpan.AddAttribute("SessionID", session.ID) rootSpan.AddAttribute("WorkDir", session.WorkDir)
defer func() { rootSpan.EndSpan() _ = observability.ExportTraceToFile(rootSpan, session.WorkDir, session.ID) log.Printf("📊 [Tracing] 本次任务的执行回放链路已保存至工作区的 .claw/traces 目录下\n") }()
composer := ctxpkg.NewPromptComposer(session.WorkDir, e.PlanMode) systemMsg := composer.Build()
turnCount := 0 for { turnCount++
turnCtx, turnSpan := observability.StartSpan(ctx, fmt.Sprintf("Turn-%d", turnCount)) defer turnSpan.EndSpan()
availableTools := e.registry.GetAvailableTools() workingMemory := session.GetWorkingMemory(20)
var contextHistory []schema.Message contextHistory = append(contextHistory, systemMsg) contextHistory = append(contextHistory, workingMemory...) compactedContext := e.compactor.Compact(contextHistory)
turnSpan.AddAttribute("context_message_count", len(compactedContext))
var currentTurnThinkingContent string
if e.EnableThinking { if reporter != nil { reporter.OnThinking(turnCtx) }
thinkCtx, thinkSpan := observability.StartSpan(turnCtx, "LLM.Thinking") thinkResp, err := e.provider.Generate(thinkCtx, compactedContext, nil) thinkSpan.EndSpan()
if err != nil { return fmt.Errorf("Thinking 阶段失败: %w", err) } if thinkResp.Content != "" { currentTurnThinkingContent = thinkResp.Content compactedContext = append(compactedContext, *thinkResp) } }
actCtx, actSpan := observability.StartSpan(turnCtx, "LLM.Action") actionResp, err := e.provider.Generate(actCtx, compactedContext, availableTools) actSpan.EndSpan()
if err != nil { return fmt.Errorf("Action 阶段失败: %w", err) }
session.Append(*actionResp)
if len(actionResp.ToolCalls) == 0 { turnSpan.EndSpan() break }
observationMsgs := make([]schema.Message, len(actionResp.ToolCalls)) var wg sync.WaitGroup
for i, toolCall := range actionResp.ToolCalls { wg.Add(1) go func(idx int, call schema.ToolCall) { defer wg.Done() ... ...
result := e.registry.Execute(turnCtx, call)
observationMsgs[idx] = schema.Message{ Role: schema.RoleUser, Content: result.Output, ToolCallID: call.ID, } }(i, toolCall) }
wg.Wait() session.Append(observationMsgs...)
turnSpan.EndSpan()
}
return nil }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| package tools
import ( "github.com/yourname/go-tiny-claw/internal/observability" )
func (r *registryImpl) Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult { ctx, span := observability.StartSpan(ctx, "Tool.Execute") span.AddAttribute("tool_name", call.Name) span.AddAttribute("arguments", string(call.Arguments))
defer span.EndSpan()
tool, exists := r.tools[call.Name] if !exists { }
for _, mw := range r.middlewares { allowed, reason := mw(ctx, call) if !allowed { span.AddAttribute("intercepted", true) span.AddAttribute("reject_reason", reason) } }
output, err := tool.Execute(ctx, call.Arguments)
if err != nil { span.AddAttribute("error", err.Error()) }
span.AddAttribute("output_preview", truncate(output, 100))
return schema.ToolResult{ } }
func truncate(s string, max int) string { if len(s) > max { return s[:max] + "..." } return s }
|