核心摘要
用 Eino 实现 Skill 执行,并支持 MCP 工具调用,最稳妥的方式是:把 Skill 当成带策略边界的运行时单元,而不是一段直接调用外部系统的脚本。Skill 声明它能使用什么能力;运行时负责解析 MCP 工具、适配成 Eino Tool、创建请求级 Agent,并通过 OpenTelemetry 追踪完整执行链路。本文用电商订单诊断 Skill 作为贯穿示例,因为这个场景能真实体现 Agent 为什么需要受控读取订单、支付、库存、风控和退款策略等业务数据。
目录
- 核心要点
- 运行时架构
- Skill 包与运行时策略
- MCP 到 Eino Tool 的适配器
- 请求级 Agent
- Trace 设计
- 权限守卫
- Skill 调用 Skill
- 测试策略
- 最佳实践
- 常见问题
- 相关资源
核心要点
- Skill = 指令 + 元数据 + 运行时策略:Skill 文档描述行为,运行时策略声明工具和限制。
- MCP 是工具来源:MCP 工具应该适配成 Eino Tool,而不是硬编码在 Skill 文本里。
- 请求级 Agent 更适合生产:用户、租户、区域、模型和 trace metadata 都可以按请求注入。
- Trace 是运行时能力:
skill.run、agent.loop、llm.call、tool.call、skill.child.run应该串在同一条 trace 下。 - Guardrail 必须写成代码:allowlist、schema validation、副作用控制、timeout、retry 和审计不能只依赖 prompt。
运行时架构
Skill 一开始可能只是指令,但生产级 Skill Runtime 必须同时管理工具、策略、限制、追踪和失败处理。
边界要拆清楚:
| 层级 | 职责 | 应避免 |
|---|---|---|
| Skill 文档 | 任务行为、示例、领域规则 | 密钥、原始 client、权限逻辑 |
| Runtime policy | 允许的 MCP 工具、限制、输出契约 | 大段推理指令 |
| Runtime | 加载 Skill、渲染 prompt、构造 Agent、挂载工具 | 外部 API 的具体实现 |
| Tool adapter | 把 MCP descriptor 转成 Eino Tool 并调用 MCP | 只靠 prompt 的安全控制 |
| Guard 层 | 权限、schema、配额、副作用检查 | 模型推理 |
| Trace 层 | 执行 span 和 metadata | 默认记录完整敏感 payload |
这个架构把 工具调用 变成受控能力面。模型仍然决定什么时候用工具,但运行时决定有哪些工具,以及这次调用是否允许。
Skill 包与运行时策略
业界并没有一个统一的“Skill manifest”标准来定义 allowed_mcp 这类字段。更稳妥的生产约定是:把面向模型和人的 Skill 指令,与运行时强制执行的策略拆开。
用 SKILL.md 保存可迁移的 Skill 指令和基础元数据:
---
name: commerce-order-triage
description: 使用只读业务数据 MCP 工具诊断电商订单问题。
---
# 电商订单诊断
当客服或商家运营询问订单为什么延迟、支付为什么异常、履约为什么阻塞、
或退款处理规则不清楚时,使用这个 Skill。它应该读取业务数据、交叉核对
多个信号,并返回可执行的诊断结论。它不能修改订单、发起退款、发送消息
或调整库存。
## 工作流
1. 从请求中识别订单 ID、区域、商家 ID 和买家 ID。
2. 按需读取订单、支付、库存、风控和退款策略数据。
3. 交叉核对时间戳、状态流转、支付事件、库存占用、发货阻塞原因和退款资格。
4. 返回可能根因、证据、置信度,以及客服或运营下一步建议。
## MCP 使用指引
- 当请求中包含订单 ID,但当前订单状态、商家、买家、区域或履约状态未知时,
优先使用 `commerce_order.get_order_detail`。
- 当订单涉及待支付、已支付但未确认、拒付、授权过期、支付与退款金额不一致时,
使用 `commerce_payment.get_payment_events`。
- 当履约阻塞、发货延迟、库存分配失败,或订单包含预售、多仓 SKU 时,
使用 `commerce_inventory.get_sku_stock`。
- 当订单被拦截、进入人工审核、高客单价、近期改地址,或买家行为异常时,
使用 `commerce_risk.get_user_risk_profile`。
- 只有用户询问退款资格、取消规则、退货窗口或补偿策略时,
才使用 `commerce_refund.get_refund_policy`。
- 如果用户已经在对话中提供了足够的订单、支付、库存和策略事实,不要调用 MCP。
- 这个 Skill 绝不能调用写入、退款、取消订单、库存调整、通知发送或破坏性 MCP。
如果确实需要执行动作,只返回建议操作,并要求进入显式动作 workflow。
再把工具权限放到执行引擎自己的运行时策略文件里:
apiVersion: qubittool.ai/v1alpha1
kind: SkillRuntimePolicy
metadata:
name: commerce-order-triage
skillFile: SKILL.md
spec:
toolAccess:
mcp:
- server: commerce_order
tool: get_order_detail
mode: read
- server: commerce_payment
tool: get_payment_events
mode: read
- server: commerce_inventory
tool: get_sku_stock
mode: read
- server: commerce_risk
tool: get_user_risk_profile
mode: read
- server: commerce_refund
tool: get_refund_policy
mode: read
limits:
maxSteps: 18
maxToolCalls: 12
timeoutMs: 120000
maxChildSkillDepth: 1
output:
format: markdown
requireSummary: true
这样做有三个收益:
- 模型看到的工具集合更小、更相关。
- 运行时拥有确定性的策略边界,同时不把非标准字段伪装成 Skill 标准。
- 评审者可以把工具权限和指令文本分开审计。
MCP 输入参数建议使用严格 JSON Schema。设计和调试 schema 时,可以使用 JSON Formatter 与 JSON Schema Generator,在运行前发现参数结构问题。
MCP 到 Eino Tool 的适配器
关键实现是一个 adapter:把 MCP descriptor 转换成 Eino 可识别的 Tool。
type MCPToolDescriptor struct {
ServerName string
ToolName string
Description string
InputSchema json.RawMessage
}
type MCPClient interface {
CallTool(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
}
type ToolGuard interface {
Validate(ctx context.Context, req ToolCallRequest) error
}
type ToolCallRequest struct {
RequestID string
SkillName string
ServerName string
ToolName string
Args json.RawMessage
}
然后暴露成 Eino Tool:
type MCPToolAdapter struct {
desc MCPToolDescriptor
client MCPClient
guard ToolGuard
tracer trace.Tracer
}
func (t *MCPToolAdapter) Info(ctx context.Context) (*schema.ToolInfo, error) {
params, err := schemaFromJSON(t.desc.InputSchema)
if err != nil {
return nil, err
}
return &schema.ToolInfo{
Name: "mcp_" + t.desc.ServerName + "_" + t.desc.ToolName,
Description: t.desc.Description,
Parameters: params,
}, nil
}
func (t *MCPToolAdapter) InvokableRun(ctx context.Context, args string) (string, error) {
ctx, span := t.tracer.Start(ctx, "tool.call",
trace.WithAttributes(
attribute.String("tool.type", "mcp"),
attribute.String("mcp.server", t.desc.ServerName),
attribute.String("mcp.tool", t.desc.ToolName),
attribute.String("args_hash", hashString(args)),
),
)
defer span.End()
rawArgs := json.RawMessage(args)
req := ToolCallRequest{
RequestID: requestIDFromContext(ctx),
SkillName: skillNameFromContext(ctx),
ServerName: t.desc.ServerName,
ToolName: t.desc.ToolName,
Args: rawArgs,
}
if err := t.guard.Validate(ctx, req); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "tool guard rejected")
return "", err
}
output, err := t.client.CallTool(ctx, t.desc.ServerName, t.desc.ToolName, rawArgs)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return "", err
}
span.SetAttributes(attribute.Int("output_bytes", len(output)))
span.SetStatus(codes.Ok, "")
return string(output), nil
}
不同 Eino 版本的 Tool interface 细节可能不同,但边界应保持一致:
Info暴露 name、description 和 schema。InvokableRun执行真实工具调用。ToolGuard在 MCP 请求离开进程前做策略校验。- Adapter 记录 span,但默认不记录敏感 payload。
如果 MCP 返回复杂嵌套 JSON,可以用 JSON to Go 生成类型化结构体用于后处理。比起在运行时到处传 map[string]any,类型化结构更容易演进和测试。
请求级 Agent
除非 profiling 证明构造成本过高,否则建议每个请求创建 Agent。这样动态状态是显式的。
type SkillRuntime struct {
Loader SkillLoader
MCPResolver MCPToolResolver
Prompt PromptRenderer
Guard ToolGuard
Tracer trace.Tracer
}
type SkillRunRequest struct {
RequestID string
UserID string
Region string
SkillName string
Input string
Model model.ChatModel
}
func (r *SkillRuntime) RunSkill(ctx context.Context, req SkillRunRequest) (*SkillRunResult, error) {
ctx, span := r.Tracer.Start(ctx, "skill.run",
trace.WithAttributes(
attribute.String("request_id", req.RequestID),
attribute.String("skill_name", req.SkillName),
attribute.String("user_id", req.UserID),
attribute.String("region", req.Region),
),
)
defer span.End()
ctx = withRequestMetadata(ctx, req)
skill, err := r.Loader.Load(ctx, req.SkillName)
if err != nil {
span.RecordError(err)
return nil, err
}
mcpTools, err := r.MCPResolver.Resolve(ctx, skill.Policy.ToolAccess.MCP)
if err != nil {
span.RecordError(err)
return nil, err
}
tools := make([]tool.BaseTool, 0, len(mcpTools))
for _, desc := range mcpTools {
tools = append(tools, NewMCPToolAdapter(desc, r.Guard, r.Tracer))
}
systemPrompt := r.Prompt.Render(skill, PromptData{
UserID: req.UserID,
Region: req.Region,
Tools: mcpTools,
Limits: skill.Policy.Limits,
})
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Model: req.Model,
Tools: tools,
SystemPrompt: systemPrompt,
MaxSteps: skill.Policy.Limits.MaxSteps,
})
if err != nil {
span.RecordError(err)
return nil, err
}
out, err := agent.Run(ctx, req.Input)
if err != nil {
span.RecordError(err)
return nil, err
}
span.SetStatus(codes.Ok, "")
return &SkillRunResult{Content: out.Content}, nil
}
这个模式适合多租户、多区域系统。运行时可以在构造 Agent 前选择区域 MCP endpoint、租户 allowlist 和模型策略。
Trace 设计
一个有用的 Skill trace 应该能看到完整链路:
skill.run
skill.load
prompt.render
agent.loop
llm.call
tool.call: mcp.commerce_order.get_order_detail
tool.call: mcp.commerce_payment.get_payment_events
tool.call: mcp.commerce_inventory.get_sku_stock
llm.call
skill.output
Eino 内部建议通过 callback 接入:
func BuildTraceHandler(tracer trace.Tracer) callbacks.Handler {
return callbacks.NewHandlerBuilder().
OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
ctx, span := tracer.Start(ctx, info.Name,
trace.WithAttributes(
attribute.String("component", string(info.Type)),
attribute.String("run_name", info.Name),
attribute.String("request_id", requestIDFromContext(ctx)),
),
)
return context.WithValue(ctx, spanKey{}, span)
}).
OnEndFn(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
if span := spanFromContext(ctx); span != nil {
span.SetAttributes(attribute.Int("output_size", sizeOf(output)))
span.SetStatus(codes.Ok, "")
span.End()
}
return ctx
}).
Build()
}
建议固定以下属性:
| Attribute | 用途 |
|---|---|
request_id |
串联日志、trace 和用户反馈 |
skill_name |
识别执行的 Skill |
skill_version |
定位版本相关行为 |
model |
对比模型行为和成本 |
tool.type |
mcp、skill、local 或 retriever |
mcp.server |
MCP Server 身份 |
mcp.tool |
MCP Tool 身份 |
args_hash |
不记录明文参数也能定位重复调用 |
output_bytes |
发现异常大的工具返回 |
retry_count |
区分依赖抖动和模型行为问题 |
默认不要把完整 prompt、用户输入、MCP 参数或 MCP 返回写入 trace。优先记录 hash、size 和 schema version。确实需要完整 payload 时,用受控采样、权限控制和短保留周期。
权限守卫
Prompt 指令不是安全边界。运行时必须用代码强制执行策略。
type CompositeGuard struct {
SkillPolicy SkillPolicyStore
UserPolicy UserPolicyStore
Validator JSONSchemaValidator
SideEffects SideEffectPolicy
Quota QuotaLimiter
}
func (g *CompositeGuard) Validate(ctx context.Context, req ToolCallRequest) error {
if !g.SkillPolicy.Allows(req.SkillName, req.ServerName, req.ToolName) {
return ErrToolNotAllowedBySkill
}
if !g.UserPolicy.Allows(userIDFromContext(ctx), req.ServerName, req.ToolName) {
return ErrToolNotAllowedByUser
}
if err := g.Validator.Validate(req.ServerName, req.ToolName, req.Args); err != nil {
return err
}
if err := g.SideEffects.Check(ctx, req); err != nil {
return err
}
return g.Quota.Allow(ctx, req)
}
副作用要明确分类:
| 模式 | 示例 | 默认策略 |
|---|---|---|
| 只读 | 查询订单详情、支付事件、库存占用、用户风控画像 | 运行时策略声明后允许 |
| 写入 | 添加客服备注、创建补偿工单、更新订单标签 | 需要策略放行和确认 |
| 破坏性 | 取消订单、发起退款、调整库存、撤销优惠券 | 默认拒绝 |
| 对外通信 | 给买家发消息、通知商家、触发 webhook | 需要显式流程门禁 |
MCP annotation 中的 read-only 或 destructive hint 很有用,但不应该成为唯一真相。它们应该只是本地策略引擎的输入。
Skill 调用 Skill
如果需要一个 Skill 调用另一个 Skill,可以把 SkillRunner 本身暴露成 Eino Tool。
type SkillTool struct {
runtime *SkillRuntime
tracer trace.Tracer
}
func (t *SkillTool) InvokableRun(ctx context.Context, args string) (string, error) {
ctx, span := t.tracer.Start(ctx, "skill.child.run")
defer span.End()
req, err := parseChildSkillRequest(args)
if err != nil {
span.RecordError(err)
return "", err
}
if err := validateChildSkillPolicy(ctx, req); err != nil {
span.RecordError(err)
return "", err
}
result, err := t.runtime.RunSkill(ctx, req.ToSkillRunRequest())
if err != nil {
span.RecordError(err)
return "", err
}
return result.Content, nil
}
启用嵌套 Skill 前必须限制:
max_depthmax_total_tool_callsallowed_child_skills- 环路检测
- 输出大小限制
- 继承父 trace context
Skill 嵌套适合做任务拆分,但没有限制时很容易产生递归循环和不可预测成本。
测试策略
建议分层测试:
| 层级 | 测试目标 | 验证内容 |
|---|---|---|
| 单测 | Runtime policy parser | 必填字段、非法 allowlist、默认 limits |
| 单测 | MCP adapter | Schema 转换、Guard 拒绝、timeout、错误映射 |
| 单测 | Prompt renderer | Metadata 注入和 prompt 稳定性 |
| 集成测试 | Fake MCP server | tools/list、tools/call、异常响应 |
| Agent 测试 | Eino Agent + mock model | 工具选择和循环终止 |
| Trace 测试 | OTel exporter | Span 名称、父子关系、attribute |
| Eval | 真实模型场景 | 成功率、无用工具调用、拒绝行为 |
策略单测示例:
func TestMCPToolAdapterRejectsUnauthorizedTool(t *testing.T) {
adapter := NewMCPToolAdapter(
MCPToolDescriptor{
ServerName: "commerce_refund",
ToolName: "issue_refund",
Description: "Issue a refund for an order",
},
fakeClient{},
denyAllGuard{},
noopTracer{},
)
_, err := adapter.InvokableRun(context.Background(), `{"order_id":"792318","amount":12900}`)
if !errors.Is(err, ErrToolNotAllowedBySkill) {
t.Fatalf("expected policy rejection, got %v", err)
}
}
行为回归建议维护小型 golden dataset:
- name: "delayed ecommerce order triage"
skill: "commerce_order_triage"
input: "Order 792318 was paid yesterday but is still not shipped. Diagnose why."
expected_tools:
- "mcp_commerce_order_get_order_detail"
- "mcp_commerce_payment_get_payment_events"
- "mcp_commerce_inventory_get_sku_stock"
forbidden_tools:
- "mcp_commerce_refund_issue_refund"
- "mcp_commerce_order_cancel_order"
max_tool_calls: 6
最佳实践
Skill 文件保持声明式。 Skill 文档负责指令、示例和决策规则;运行时策略负责工具访问和限制;权限判断写在 Go 代码里。
优先请求级构造。 除非 profiling 证明构造成本明显,否则每次请求创建 Agent。metadata、工具面和 trace context 都会更清楚。
缩小工具面。 一个 Skill 看到 5 个相关工具,通常比看到几十个泛化工具更可靠。
工具输出结构化。 MCP 工具建议返回稳定 JSON 对象,包含 status、data、error_code、message 等字段。
所有边界都要 Trace。 至少记录 Skill load、prompt render、LLM call、MCP call、child Skill call 和 final output。
敏感输入只记录 hash。 用 args_hash、input_hash、output_bytes 替代完整 payload。
副作用默认关闭。 只读工具可以 allowlist;写入、发布、删除、对外通信需要显式流程门禁。
常见问题
Skill 和 MCP Tool 有什么区别?
Skill 是任务级行为包,包含指令、策略、限制和输出要求。MCP Tool 是通过 Model Context Protocol 暴露的具体外部能力。一个 Skill 可以使用多个 MCP Tool。
MCP descriptor 应该启动时加载还是请求时加载?
建议启动时或短 TTL 缓存 descriptor,但每次请求都重新过滤允许工具。descriptor cache 提升性能,请求级过滤保证权限隔离。
这个架构可以用 Eino Graph,而不是 ADK Agent 吗?
可以。流程确定时用 Graph;工具选择和执行顺序高度动态时用 ReAct 风格 Agent。很多系统会用 Graph 做外层确定性编排,用 Agent 做内层灵活工具调用。
如何控制成本?
设置 max_steps、max_tool_calls、单工具 timeout、输出大小限制和 token budget。把 token usage 和 tool count 写入 trace attribute。
嵌套 Skill 安全吗?
只有在有深度限制、allowlist、环路检测和共享 quota 时才安全。否则很容易递归或产生不可预测成本。