Day 10 / 共 20 天 · 第 2 周 分层与契约 · 收官

Prompt 调试与 LLM Runtime

Day 09 讲了"创建 Prompt",今天讲另一件更常用的事——用户在页面上点"运行"按钮调试 Prompt,请求最终是怎么真正发到大模型(比如豆包/GPT/Claude)那里去的。这是第 2 周的最后一天。

📍 你在整门课的位置 · 第 2 周 分层与契约(共 4 周 · 20 天)
D8 Foundation D9 Prompt 读路径 D10 调试与 LLM ✓ 第2周完结
L01

调试功能解决什么问题

🤔 场景:你刚写好一个 Prompt,怎么知道它效果好不好? 在正式把 Prompt 用到生产环境之前,需要一个"边改边看效果"的调试界面——改一句话,立刻看模型怎么回答,这就是 Coze Loop Prompt 开发(README.cn.md 里"提示词开发"能力)的核心体验。它对应后端 backend/modules/prompt/application/debug.goexecute.go 两个文件。
💡 本质:调试 = "临时执行一次 Prompt,但不改变它的正式版本" 和 Day 09 的 CreatePrompt(写数据)不同,调试是"读 Prompt 内容 + 调用 LLM + 把结果返回给前端",几乎不产生持久化数据(只留 Trace 观测数据,Day 14 会讲)。
L02

DebugStreaming:权限与追踪

backend/modules/prompt/application/debug.go 第 66 行的 DebugStreaming,开场就是 Day 08/09 学过的"老熟人"——权限校验,只是这里多了一个分支:

func (p *PromptDebugApplicationImpl) DebugStreaming(ctx context.Context, req *debug.DebugStreamingRequest, stream debug.PromptDebugService_DebugStreamingServer) (err error) {
	err = validateDebugStreamingRequest(req)
	var callType string
	if req.Prompt.GetID() == 0 {
		// 情况一:Playground(还没保存成正式 Prompt,边写边试)
		callType = consts.SpanTagCallTypePromptPlayground
		req.Prompt.PromptKey = ptr.Of(fmt.Sprintf("playground-%s", session.UserIDInCtxOrEmpty(ctx)))
		err = p.auth.CheckSpacePermission(ctx, req.Prompt.GetWorkspaceID(), consts.ActionWorkspaceCreateLoopPrompt)
	} else {
		// 情况二:调试一个已存在的 Prompt
		callType = consts.SpanTagCallTypePromptDebug
		err = p.auth.MCheckPromptPermission(ctx, req.Prompt.GetWorkspaceID(), []int64{req.Prompt.GetID()}, consts.ActionLoopPromptDebug)
	}
	// 开始一个 Trace Span,记录这次调用类型/用户/版本
	ctx, span = looptracer.GetTracer().StartSpan(ctx, consts.SpanNamePromptExecutor, consts.SpanTypePromptExecutor, ...)
	// ...
}
Playground vs Debug 的区别req.Prompt.GetID() == 0 说明这个 Prompt 还没有真实 ID(还没存过),属于"临时试验场"(Playground);有 ID 则是调试一个已经 CreatePrompt 存过的正式 Prompt。两者走不同的权限校验函数——Playground 检查"能不能创建",正式调试检查"对这个具体 Prompt 有没有调试权限"。
为什么函数名带 Streaming:大模型的回复是逐字生成的,为了让用户实时看到"打字机效果",这里用的是流式接口(stream debug.PromptDebugService_DebugStreamingServer),而不是等模型把话说完再一次性返回。
L03

execute.go:真正发起调用

backend/modules/prompt/application/execute.go 里的 ExecuteInternal 是"渲染 Prompt 模板 + 调用 LLM"的执行核心,也用 Trace Span 包了一层:

🍼 类比:把菜谱和食材交给厨师 Prompt 模板就像"菜谱"(里面有变量占位符,比如 {{user_name}}),调试时填入的变量就是"食材"。ExecuteInternal 先把菜谱和食材结合,渲染出一份"最终点单"(真正发给模型的完整文本),再交给"厨师"(LLM 模块)去做。
⚠️ 坑:Prompt 模块自己不直接调用大模型 SDK execute.go/debug.go 只负责渲染模板、编排流程、记录 Trace,真正"跟大模型说话"的逻辑,全部委托给了另一个独立模块——llm。回顾 Day 06 讲的"模块间不直接互调",prompt 模块调用 llm 模块,走的也是 Day 05 讲的"本地 Kitex 客户端"模式。
L04

llm 模块 Runtime.Chat

请求走到 backend/modules/llm/application/runtime.goChat 方法——这是 llm 模块对外提供的核心能力:

func (r *runtimeApp) Chat(ctx context.Context, req *runtime.ChatRequest) (resp *runtime.ChatResponse, err error) {
	resp = runtime.NewChatResponse()
	if err = r.validateChatReq(ctx, req); err != nil {
		return resp, errorx.NewByCode(llm_errorx.RequestNotValidCode, errorx.WithExtraMsg(err.Error()))
	}
	// 1. 模型信息获取 —— 根据 model_config.yaml 里配置的 model id 查出协议/密钥等
	// 2. 构建对应协议的 Eino ChatModel
	// 3. 调用 Generate/Stream,拿到模型输出
	// ...
}
💡 llm 模块的职责边界:把"业务用哪个模型"和"具体怎么调用这个模型"分开 prompt 模块只知道"我要用 model_id=1 这个模型聊天",完全不关心这个 id 背后是豆包、GPT 还是 Claude、用什么协议、密钥是什么——这些全部封装在 llm 模块内部,这正是 modules/llm 单独成为一个 DDD 模块(而不是 prompt 模块内部的一个工具函数)的原因:让"模型接入"这件事可以独立演进,不影响 prompt 模块的代码。
L05

Eino:多协议大模型适配层

llm 模块内部真正"跟模型说话"的代码在 backend/modules/llm/domain/service/llmimpl/eino/llm.go,它基于字节跳动开源的 Eino LLM 应用开发框架(backend/go.mod 里能看到 cloudwego/einoeino-ext 依赖):

type LLM struct {
	frame     entity.Frame
	protocol  entity.Protocol
	chatModel IEinoChatModel   // 底层就是 einoModel.ToolCallingChatModel 接口
}

func (l *LLM) Generate(ctx context.Context, input []*entity.Message, opts ...entity.Option) (*entity.Message, error) {
	optsDO := entity.ApplyOptions(nil, opts...)
	einoOpts, _ := entity.FromDOOptions(optsDO)
	einoTools, _ := entity.FromDOTools(optsDO.Tools)      // 绑定工具(Function Calling)
	if len(einoTools) > 0 {
		l.chatModel, _ = l.chatModel.WithTools(einoTools)
	}
	einoMsg, err := l.chatModel.Generate(ctx, entity.FromDOMessages(input), einoOpts...)   // 真正调用模型
	return entity.ToDOMessage(einoMsg)   // Eino 消息格式 → Coze Loop 自己的 entity.Message
}

backend/modules/llm/domain/service/llmimpl/eino/init.goNewLLM 根据配置的 Protocol 选择不同的底层实现(都是 eino-ext 提供的现成组件):

ark(火山方舟)
openai
claude
deepseek
gemini
ollama(本地)
qwen
qianfan(文心)
为什么用 Eino 而不是自己写每家 SDK 的适配代码? 各家大模型的 API 格式、鉴权方式、流式协议都不一样。Eino 把这些差异封装成统一的 ToolCallingChatModel 接口,Coze Loop 只需要针对这一个接口写 Generate/Stream 逻辑,新增一家模型厂商时,只要在 init.go 里加一个 xxxBuilder 分支即可,不用改 llm.go 里的业务代码。这与 Day 06 讲的"面向接口而非实现编程"是同一个思想的不同层面应用。
L06

model_config.yaml 怎么接进来

回到 Day 02 见过的 release/deployment/docker-compose/conf/model_config.yaml——这份配置就是上面 entity.ModelProtocol/ProtocolConfig 等字段)的数据来源:

models:
  - id: 1
    name: "doubao"
    frame: "eino"        # 对应 entity.Frame,目前固定用 eino
    protocol: "ark"       # 对应 entity.ProtocolArk,决定 init.go 走哪个 builder
    protocol_config:
      api_key: "***"       # 火山方舟 API Key,本地部署要自己填
      model: "***"          # 具体的 endpoint id
    param_config:
      param_schemas:
        - name: "temperature"
          default_val: "0.7"   # 前端调试面板展示的可调参数,默认值来自这里
串起 Day 02 和今天:Day 02 提到"配火山方舟"其实就是填这个文件里的 api_keymodelid: 1 就是 Runtime.Chat 里请求参数中的 model id,一整条链路从"配置文件里的一行 YAML"到"真正打到模型服务商的一次 HTTP 调用",今天全部打通了。
L07

今日小结 + 第一周总结

🧠 今天你应该能回答

  • Playground 和正式调试的区别?(有没有真实 Prompt ID,走不同权限校验)
  • 为什么 prompt 模块不直接调用大模型 SDK?(职责分离,交给独立的 llm 模块)
  • Eino 在架构里的作用?(统一多家大模型协议差异的适配层)
  • model_config.yaml 里的字段和代码里的哪些概念对应?(protocol → builder 分支,param_schemas → 调试面板参数)
第 2 周「分层与契约」完结
Day 06-10:DDD 分层、Wire 依赖注入、IDL 契约、Foundation 鉴权、Prompt 全链路纵切与 LLM Runtime——你已经能独立看懂一个完整业务请求的生命周期。

✋ 动手 5 分钟

# 1. 看今天走过的两个入口文件
grep -n "func.*DebugStreaming\|func.*ExecuteInternal" backend/modules/prompt/application/debug.go backend/modules/prompt/application/execute.go

# 2. 看 llm 模块支持哪些协议(今天讲的 8 种只是其中一部分)
grep -n "case entity.Protocol" backend/modules/llm/domain/service/llmimpl/eino/init.go

# 3. 看 eino 和 eino-ext 的版本
grep -n "cloudwego/eino" backend/go.mod

# 4. 完整读一遍配置示例,体会字段和代码的对应关系
cat release/deployment/docker-compose/conf/model_config.yaml
明天预告 · Day 11:第 3 周开始讲「评测」——先看 Dataset(数据集)模块:评测的原材料从哪来、Schema / 导入 / 版本怎么管。
← 上一天 · Prompt 读路径纵切 下一天 · Dataset 数据集 →