AI应用开发中思考过程与正文输出的分离实践
1. 项目概述为什么要在AI项目中区分“思考”与“正文”最近在参与一个基于CloudWeGo和Eino框架的AI应用开发项目遇到了一个挺有意思的挑战如何让AI模型的“思考过程”和最终输出的“正文内容”在代码和日志里清晰地区分开来。这听起来像是个纯工程问题但实际做起来你会发现它直接关系到系统的可观测性、调试效率甚至是最终的用户体验。想象一下这个场景你部署了一个AI智能客服或者一个内容生成Agent。用户问了一个复杂问题AI模型在后台可能进行了多步推理、检索了知识库、自我纠正了逻辑最后才生成一段简洁的回复。如果日志里只有最终的那句回复开发者和运维同学就像在看一部被剪掉了所有幕后花絮和NG镜头的电影——你只知道结局完全不知道剧情是怎么推进的更别提在出问题时快速定位了。那个“幕后花絮”就是AI的思考过程Reasoning Trace或Chain-of-Thought而“正片”就是最终呈现给用户的正文内容Final Output。在CloudWeGo这套高性能、微服务友好的Go生态里做这件事尤其有意义。CloudWeGo的组件比如Kitex和Hertz天生就为高并发、低延迟的RPC和HTTP通信设计。当AI能力作为微服务被集成时每一次调用都可能包含复杂的内部思考链路。如果能把思考过程的中间状态比如“用户意图识别为A”、“从数据库检索到B和C两条信息”、“根据规则D排除了C”、“最终综合B生成回答”也结构化的记录下来并通过CloudWeGo的tracing、logging体系无缝传递那对于问题诊断、效果优化和成本分析价值巨大。Eino框架作为专注于AI应用开发的框架其核心目标之一就是简化AI能力的集成与编排。在这个上下文中区分思考与正文就不再是“锦上添花”而是“雪中送炭”的基础能力。它能让开发者在框架层面就定义好思考链路的模板将内部推理逻辑与对外输出格式解耦使得AI服务的行为更加透明、可控和可维护。所以这个技术方案要解决的远不止是“打两行不同的日志”那么简单。它关乎如何在CloudWeGo的微服务架构和Eino的AI编排框架下设计一套统一、高效、对业务无侵入的机制来捕获、传递、存储和呈现AI服务的“内心戏”与“最终台词”。这对于构建可靠、可解释、易运维的下一代AI应用至关重要。2. 核心设计思路构建双层输出管道要清晰地区分思考过程和正文内容最直观的思路是建立“双层管道”。你可以把它想象成工厂里的两条流水线一条是内部调试线展示每个零件的加工、检测、组装过程思考过程另一条是最终包装线只输出完美的成品正文内容。我们的技术方案就是为AI服务搭建这样两条并行的输出流。2.1 逻辑分离定义清晰的边界首先我们需要在逻辑上明确什么是“思考过程”什么是“正文内容”。思考过程是一个动态的、结构化的数据序列。它记录了AI模型或Agent在生成最终答案前所做的所有内部工作。这可能包括步骤分解将复杂问题拆解成的子任务。工具调用调用了哪个搜索API、数据库查询函数或计算工具以及调用的参数和返回的原始结果。中间推理基于上一步的结果得出的临时结论或判断。自我验证与修正对之前步骤的检查以及发现错误后的调整路径。候选与淘汰生成多个备选答案以及选择最终答案的理由。这些信息通常是树状或图状结构而不是简单的线性文本。正文内容则是最终确定要返回给调用方前端、用户或其他服务的数据。它通常是精炼的、格式化的并且符合接口契约。对于聊天场景它就是一条消息对于摘要任务它就是一段摘要文本对于数据提取它可能是一个JSON对象。设计的核心原则是思考过程服务于开发者、运维和算法工程师用于分析、调试和优化正文内容服务于最终用户或下游系统用于实现业务功能。两者必须隔离确保思考过程中的调试信息、中间错误或冗余内容不会污染最终输出。2.2 架构模式装饰器与中间件在CloudWeGo和Eino的架构下实现这种分离有几种成熟的模式最推荐的是“装饰器模式”结合“上下文传递”。1. 在AI模型/Agent调用层使用装饰器我们可以在调用大模型如通过OpenAI API、本地部署的模型或执行Agent工作流的代码外围包裹一个装饰器。这个装饰器的主要职责是在调用开始前初始化一个用于收集思考过程的结构体例如一个ThinkingTrace的切片或链表并将其挂载到Go的context.Context中。执行真正的AI调用逻辑。在逻辑执行过程中任何需要记录思考步骤的地方都从context中取出ThinkingTrace并追加记录。调用结束后装饰器能同时获得原始的思考过程记录和模型返回的原始内容。装饰器内部再包含一个“后处理”逻辑这个逻辑负责从原始内容中提取或加工出最终的“正文内容”。这样业务代码只需要关心核心逻辑而思考过程的收集和正文的提炼被隔离在了装饰器这一层。2. 利用Eino框架的“环节”抽象如果使用Eino框架它的设计哲学通常会将一个AI任务分解为多个“环节”。我们可以标准化一个环节的输出格式。例如规定每个环节的输出都包含两个字段type StepOutput struct { Thinking string json:thinking // 本环节的思考过程 Content string json:content // 本环节产出的内容可能是中间内容也可能是最终内容 }框架负责在串联各个环节时将上一个环节的Content传递给下一个环节作为输入同时将所有环节的Thinking收集起来形成完整的思考链。最后一个环节的Content就被当作最终的正文内容。这种方式将分离逻辑内化到了框架的工作流引擎中对开发者更友好。3. 通过CloudWeGo中间件实现日志与追踪注入在CloudWeGo Kitex服务端我们可以编写一个中间件。这个中间件在请求处理开始时检查请求的元数据例如某个特定的Header判断是否需要开启详细思考过程记录。如果需要它同样初始化一个追踪结构体放入context。之后在整个RPC处理函数执行过程中包括其中调用的所有Eino Agent逻辑都可以向这个结构体写入思考记录。最后在中间件返回响应前它可以将思考过程序列化后通过两种方式处理写入到结构化的日志系统如附加上RequestID与普通的业务日志区分开。附加到响应的扩展字段如特定的Header或一个单独的trace_id客户端可以根据这个trace_id去日志系统查询完整的思考过程。这种方式确保了思考过程数据不会增大主响应体的体积影响网络性能。3. 技术实现方案详解理论说清楚了我们来点实际的。下面我将基于Go语言和CloudWeGo生态给出一个从数据结构定义到代码集成的具体实现方案。3.1 数据结构定义如何表征思考链第一步是设计一个能充分表达思考过程的数据结构。这里我们采用一个灵活且可扩展的TraceNode链表或切片来代表一个树状的思考过程。package aicontext import ( encoding/json time ) // TraceType 定义思考节点的类型 type TraceType string const ( TraceTypeReasoning TraceType reasoning // 逻辑推理 TraceTypeToolCall TraceType tool_call // 工具调用搜索、查询、计算 TraceTypeRetrieval TraceType retrieval // 知识检索 TraceTypeFilter TraceType filter // 信息过滤或评分 TraceTypeFinal TraceType final // 最终决定 ) // TraceNode 表示思考链中的一个节点 type TraceNode struct { ID string json:id // 节点唯一ID可用于关联父子节点 ParentID string json:parent_id,omitempty // 父节点ID用于构建树形结构 Type TraceType json:type // 节点类型 Timestamp time.Time json:timestamp // 发生时间 Depth int json:depth // 在思考树中的深度 Input map[string]interface{} json:input,omitempty // 输入数据如用户问题、上一步结果 Action string json:action // 执行的动作描述如“调用谷歌搜索API” Thought string json:thought // 核心思考内容自然语言描述 Observation string json:observation,omitempty // 执行动作后的观察结果如API返回的原始数据 Metadata map[string]interface{} json:metadata,omitempty // 扩展元数据如耗时、token数、置信度 Children []*TraceNode json:children,omitempty // 子节点可选也可通过ParentID重构 } // ThinkingTrace 完整的思考过程通常是一个TraceNode的切片或根节点 type ThinkingTrace []*TraceNode // FinalOutput 最终的正文内容结构随业务而定 type FinalOutput struct { Content interface{} json:content // 正文内容可能是string、map、array等 Format string json:format,omitempty // 内容格式如“markdown”、“json” Citations []Citation json:citations,omitempty // 引用来源如果思考过程中有检索 Usage *UsageStats json:usage,omitempty // 本次调用的资源消耗 } // Citation 引用信息 type Citation struct { Source string json:source Excerpt string json:excerpt } // UsageStats 资源消耗统计 type UsageStats struct { PromptTokens int json:prompt_tokens CompletionTokens int json:completion_tokens TotalTokens int json:total_tokens TotalTimeMS int64 json:total_time_ms }这个设计的关键在于Thought和Observation的分离Thought是AI“脑子里想的”我决定去搜索XX关键词Observation是它“眼睛看到的”搜索返回了10条结果。这完美对应了ReAct等Agent框架的核心概念。结构化的Input和Metadata便于后续做分析和可视化比如我们可以轻松筛选出所有ToolCall类型的节点并统计它们的平均耗时。树形结构支持通过ParentID或Children字段可以还原复杂的、带有分支和回溯的思考路径这比线性日志强大得多。3.2 上下文集成让思考过程随调用链传递在Go中context.Context是传递请求域信息的最佳载体。我们需要定义专用的Key和工具函数来操作思考过程。package aicontext import context type traceKey struct{} // WithNewThinkingTrace 创建一个携带空思考链的新上下文 func WithNewThinkingTrace(ctx context.Context) context.Context { trace : make(ThinkingTrace, 0) return context.WithValue(ctx, traceKey{}, trace) } // AppendThinkingNode 向当前上下文的思考链追加一个节点 func AppendThinkingNode(ctx context.Context, node *TraceNode) error { v : ctx.Value(traceKey{}) if v nil { // 如果上下文没有初始化思考链可以静默忽略或返回错误取决于你的设计 // return errors.New(thinking trace not initialized in context) return nil // 选择静默忽略不影响主流程 } tracePtr, ok : v.(*ThinkingTrace) if !ok { return errors.New(invalid thinking trace type in context) } *tracePtr append(*tracePtr, node) return nil } // GetThinkingTrace 从上下文中获取完整的思考链 func GetThinkingTrace(ctx context.Context) ThinkingTrace { v : ctx.Value(traceKey{}) if v nil { return nil } tracePtr, ok : v.(*ThinkingTrace) if !ok { return nil } return *tracePtr }3.3 装饰器实现封装模型调用假设我们有一个调用OpenAI ChatCompletion的简单函数。下面展示如何用装饰器模式将其改造使其自动记录思考过程。package ai import ( context github.com/your-org/aicontext github.com/sashabaranov/go-openai ) // OpenAIDecorator 装饰器函数增强原有的模型调用逻辑 func OpenAIDecorator(client *openai.Client, originalCall func(ctx context.Context, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error)) func(ctx context.Context, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, aicontext.ThinkingTrace, error) { return func(ctx context.Context, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, aicontext.ThinkingTrace, error) { // 1. 记录请求开始 startNode : aicontext.TraceNode{ ID: generateID(), Type: aicontext.TraceTypeReasoning, Timestamp: time.Now(), Action: Start OpenAI ChatCompletion Call, Thought: Preparing to send request to model: req.Model, Input: map[string]interface{}{messages_count: len(req.Messages)}, } _ aicontext.AppendThinkingNode(ctx, startNode) // 2. 执行原始调用 resp, err : originalCall(ctx, req) // 3. 记录模型原始响应作为观察 obsNode : aicontext.TraceNode{ ID: generateID(), ParentID: startNode.ID, Type: aicontext.TraceTypeReasoning, Timestamp: time.Now(), Action: Received Model Response, Observation: resp.Choices[0].Message.Content, // 记录原始响应 Metadata: map[string]interface{}{finish_reason: resp.Choices[0].FinishReason}, } _ aicontext.AppendThinkingNode(ctx, obsNode) // 4. 可选在这里可以进行后处理从resp中提取最终正文 // finalContent : postProcess(resp) // 5. 返回响应、思考链和错误 trace : aicontext.GetThinkingTrace(ctx) return resp, trace, err } } // 业务代码使用示例 func MyBusinessLogic(ctx context.Context, userQuestion string) (*aicontext.FinalOutput, error) { // 为本次请求初始化一个携带思考链的上下文 ctxWithTrace : aicontext.WithNewThinkingTrace(ctx) // 创建被装饰的原始函数 rawCall : func(ctx context.Context, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error) { return openAIClient.CreateChatCompletion(ctx, req) } // 应用装饰器 decoratedCall : OpenAIDecorator(openAIClient, rawCall) // 构建请求 req : openai.ChatCompletionRequest{ Model: openai.GPT3Dot5Turbo, Messages: []openai.ChatCompletionMessage{ {Role: user, Content: userQuestion}, }, } // 调用装饰后的函数同时获得响应和思考链 resp, thinkingTrace, err : decoratedCall(ctxWithTrace, req) if err ! nil { // 记录错误到思考链 _ aicontext.AppendThinkingNode(ctxWithTrace, aicontext.TraceNode{ Type: aicontext.TraceTypeFinal, Thought: Model call failed, Observation: err.Error(), }) // 将思考链写入日志关联RequestID logThinkingTrace(ctxWithTrace, thinkingTrace) return nil, err } // 后处理从模型响应中提取最终正文内容 finalContent : extractFinalContent(resp) finalOutput : aicontext.FinalOutput{ Content: finalContent, Format: markdown, Usage: aicontext.UsageStats{...}, // 从resp.Usage填充 } // 记录最终决定节点 _ aicontext.AppendThinkingNode(ctxWithTrace, aicontext.TraceNode{ Type: aicontext.TraceTypeFinal, Thought: Final answer synthesized., Input: map[string]interface{}{raw_response: resp.Choices[0].Message.Content}, Observation: finalContent, }) // 将完整的思考链写入结构化日志系统或发送到追踪后端 logThinkingTrace(ctxWithTrace, thinkingTrace) // 返回最终正文内容给客户端 return finalOutput, nil }3.4 与CloudWeGo生态集成思考链的收集是第一步如何将它融入CloudWeGo的微服务可观测体系才是发挥其价值的关键。1. 日志集成我们不应该把庞大的思考链JSON直接打印到标准输出那样会污染日志。应该使用结构化的日志字段并通过RequestID进行关联。// 在Kitex/Hertz中间件或业务逻辑最后记录 func logThinkingTrace(ctx context.Context, trace aicontext.ThinkingTrace) { if len(trace) 0 { return } traceJSON, err : json.Marshal(trace) if err ! nil { klog.Error(Failed to marshal thinking trace, err) return } // 假设使用klog并已通过中间件将RequestID注入ctx klog.CtxInfof(ctx, AI_THINKING_TRACE, trace%s, string(traceJSON)) // 更佳实践将traceJSON发送到专门的日志收集通道或存储如ES日志中只留一个trace_id }在日志查询系统如ELK或Loki中你可以通过log_type:”AI_THINKING_TRACE”和request_id:”xxx”轻松找到一次请求对应的完整思考过程。2. 追踪集成CloudWeGo默认集成了OpenTelemetry。我们可以将关键的思考节点作为Span的Event或Attribute附加到现有的调用链上。import ( go.opentelemetry.io/otel/attribute go.opentelemetry.io/otel/trace ) func recordTraceNodeAsSpanEvent(span trace.Span, node *aicontext.TraceNode) { if span nil || node nil { return } // 将思考节点作为一个事件记录到当前Span span.AddEvent(node.Action, trace.WithAttributes( attribute.String(ai.thought.type, string(node.Type)), attribute.String(ai.thought.content, node.Thought), ), trace.WithTimestamp(node.Timestamp), ) // 如果Observation很重要也可以作为属性 if node.Observation ! { span.SetAttributes(attribute.String(ai.thought.observation, node.Observation)) } }这样在Jaeger或Zipkin这样的分布式追踪UI中你不仅能看到服务间的调用关系还能在某个AI服务的Span下看到它内部详细的思考步骤事件实现了宏观链路与微观推理的统一观测。3. 响应分离确保思考过程不污染正文响应的最佳实践是分通道返回。主响应通道HTTP/RPC响应体只包含FinalOutput结构。这是服务契约的一部分必须保持稳定、简洁。辅助通道通过以下方式提供思考过程Header/扩展字段在HTTP响应头或Kitex的RespExtra中返回一个X-Trace-Id。客户端凭此ID去专门的查询接口或日志系统拉取思考链。独立端点提供一个GET /debug/trace/{trace_id}的调试接口可在测试环境开启用于查询原始思考链。旁路存储在记录思考链日志时同时将其存入一个短暂的存储如Redis设置TTLtrace_id作为Key。这样查询效率更高。4. 在Eino框架中的实践与适配Eino框架如果定位为AI应用框架那么将“思考与正文分离”作为一等公民支持会极大地提升开发体验。以下是几种可能的框架级支持方式。4.1 定义标准环节接口Eino可以定义一个标准的Step接口要求每个环节如LLM调用、工具执行、条件判断都返回一个包含思考和内容的统一结构。package eino type StepContext struct { Context context.Context Input interface{} // ... 其他上下文信息 } type StepOutput struct { Thinking *aicontext.TraceNode Content interface{} Error error } type Step interface { Execute(ctx StepContext) StepOutput } // 框架引擎负责串联步骤并收集所有StepOutput中的Thinking形成Trace type WorkflowEngine struct { steps []Step } func (e *WorkflowEngine) Run(initialInput interface{}) (finalContent interface{}, thinkingTrace aicontext.ThinkingTrace, err error) { ctx : StepContext{Input: initialInput} var trace aicontext.ThinkingTrace for _, step : range e.steps { output : step.Execute(ctx) if output.Thinking ! nil { trace append(trace, output.Thinking) } if output.Error ! nil { return nil, trace, output.Error } // 当前步骤的输出内容作为下一步的输入 ctx.Input output.Content } return ctx.Input, trace, nil }4.2 提供内置的“思考记录”工具集Eino可以提供一套开箱即用的工具函数让开发者方便地在自定义步骤中记录思考。package eino import “github.com/your-org/aicontext” func RecordReasoning(ctx StepContext, thought string, observation ...string) *aicontext.TraceNode { node : aicontext.TraceNode{ Type: aicontext.TraceTypeReasoning, Thought: thought, } if len(observation) 0 { node.Observation observation[0] } // 框架自动将node挂载到本次工作流的追踪上下文中 getTraceFromContext(ctx.Context).Append(node) return node } func RecordToolCall(ctx StepContext, toolName string, input map[string]interface{}, output string) *aicontext.TraceNode { node : aicontext.TraceNode{ Type: aicontext.TraceTypeToolCall, Action: Call Tool: toolName, Input: input, Observation: output, } getTraceFromContext(ctx.Context).Append(node) return node }开发者只需在步骤逻辑中调用这些函数框架会自动完成收集和管理。4.3 配置化输出控制Eino可以通过配置文件或环境变量控制思考过程的输出粒度。# eino-config.yaml logging: thinking_trace: enabled: true level: “DETAILED” # 可选值NONE, BASIC, DETAILED, DEBUG output: - “LOG” # 输出到结构化日志 - “TRACE” # 附加到OpenTelemetry Trace storage: backend: “redis” # 可选none, redis, elasticsearch ttl: “1h”在BASIC级别只记录关键决策点在DEBUG级别记录每一个内部状态变化。这允许在生产环境中平衡可观测性和性能开销。5. 常见问题、性能考量与最佳实践在实际落地这套方案时你会遇到一些典型问题和需要权衡的地方。5.1 常见问题排查问题1思考链日志体积过大导致日志系统压力剧增。排查检查记录的TraceNode中Input和Observation字段是否包含了过大的原始数据例如整篇网页内容、大型JSON。解决采样非关键请求如健康检查或高频率请求可以按比例采样记录例如只记录1%的请求的完整思考链。截断与摘要对于过长的文本记录其哈希值如SHA256和前缀或使用AI模型生成一个简短摘要后再记录。分级存储将详细的思考链尤其是包含大块数据的存入对象存储如S3或专门的文档数据库在日志中只保留索引ID。问题2思考过程记录影响了接口响应时间P99延迟升高。排查使用性能剖析工具如pprof检查AppendThinkingNode、序列化JSONjson.Marshal以及写入日志或存储的耗时。解决异步写入不要在主请求线程中同步写入思考链到远程存储。可以将思考链数据放入一个内存通道Channel由后台goroutine异步消费并写入。确保通道有缓冲且消费速度跟得上避免内存泄漏。var traceChan make(chan *TraceData, 1000) // 缓冲通道 go func() { for data : range traceChan { // 异步写入ES/Redis等 saveTraceAsync(data) } }() // 在业务逻辑中 traceChan - TraceData{ReqID: reqID, Trace: thinkingTrace}对象池频繁创建TraceNode和序列化会带来GC压力。可以考虑使用sync.Pool来复用这些对象。问题3在复杂的并发或异步Agent中思考链顺序错乱或丢失。排查在异步回调或goroutine中使用的context可能不是携带了正确思考链的那个父context。解决显式传递在所有创建新goroutine的地方务必使用context.WithValue将父context中的思考链引用或一个线程安全的收集器传递下去。使用线程安全的收集器设计一个全局的、以RequestID为Key的思考链收集器各个goroutine都向这个收集器追加数据。请求结束时统一取出。这避免了context传递的复杂性但需要管理收集器的生命周期和清理。5.2 性能考量与优化内存占用一个复杂的思考链可能包含数十个节点。在高并发下大量未及时释放的思考链会占用可观内存。异步处理和及时清理如请求结束后显式清空context中的值是关键。序列化开销JSON序列化是CPU密集型操作。考虑使用更高效的序列化库如json-iterator/go或者对于内部传输使用Protobuf。存储选择思考链数据是写多读少通常只在调试时读且具有明显的冷热特征最近的数据更可能被查询。选择适合的存储近期数据Redis读写快支持设置TTL自动过期。长期归档与检索Elasticsearch强大的全文搜索和聚合分析能力便于事后分析AI的行为模式。成本考量对于海量数据可以将超过一定时间如7天的数据从ES迁移到更便宜的冷存储如S3并通过索引记录其位置。5.3 最佳实践总结定义即约定项目伊始团队就应对ThinkingTrace和FinalOutput的数据结构达成共识并作为跨团队API设计的一部分。上下文是王道始终坚持通过context.Context来传递思考链这是Go语言处理请求域数据的最佳实践能很好地与CloudWeGo中间件融合。异步化处理思考过程的收集、序列化、存储必须与主业务逻辑异步进行确保不影响用户端响应速度。采样与分级在生产环境务必对思考过程记录实施采样策略并根据日志级别控制其详细程度。永远记录错误请求的完整思考链。安全与隐私思考链可能包含敏感信息如用户原始输入、内部数据。在存储和传输前必须进行脱敏处理。确保调试接口有严格的权限控制。可视化工具投资开发或引入一个简单的可视化界面能够将ThinkingTrace的JSON渲染成可交互的时序图或树状图。这能极大提升调试和算法优化的效率。可以是一个内部Web工具通过trace_id查询并展示。区分AI的思考过程与正文内容本质上是在为AI系统增加“可观测性”和“可解释性”。在CloudWeGo和Eino构建的微服务AI架构中系统化地实现这一分离不仅能让你在出现问题时快速定位“AI到底哪一步想错了”更能为长期的模型迭代、提示词优化提供宝贵的数据资产。从最初的简单日志分割到如今融入分布式追踪和异步管道的完整方案这套思路已经在我们多个项目中得到了验证实实在在地降低了AI服务的运维复杂度。