核心摘要

Eino 实现 Skill 执行,并支持 MCP 工具调用,最稳妥的方式是:把 Skill 当成带策略边界的运行时单元,而不是一段直接调用外部系统的脚本。Skill 声明它能使用什么能力;运行时负责解析 MCP 工具、适配成 Eino Tool、创建请求级 Agent,并通过 OpenTelemetry 追踪完整执行链路。本文用电商订单诊断 Skill 作为贯穿示例,因为这个场景能真实体现 Agent 为什么需要受控读取订单、支付、库存、风控和退款策略等业务数据。


目录

  1. 核心要点
  2. 运行时架构
  3. Skill 包与运行时策略
  4. MCP 到 Eino Tool 的适配器
  5. 请求级 Agent
  6. Trace 设计
  7. 权限守卫
  8. Skill 调用 Skill
  9. 测试策略
  10. 最佳实践
  11. 常见问题
  12. 相关资源

核心要点

  • Skill = 指令 + 元数据 + 运行时策略:Skill 文档描述行为,运行时策略声明工具和限制。
  • MCP 是工具来源:MCP 工具应该适配成 Eino Tool,而不是硬编码在 Skill 文本里。
  • 请求级 Agent 更适合生产:用户、租户、区域、模型和 trace metadata 都可以按请求注入。
  • Trace 是运行时能力skill.runagent.loopllm.calltool.callskill.child.run 应该串在同一条 trace 下。
  • Guardrail 必须写成代码:allowlist、schema validation、副作用控制、timeout、retry 和审计不能只依赖 prompt。

运行时架构

Skill 一开始可能只是指令,但生产级 Skill Runtime 必须同时管理工具、策略、限制、追踪和失败处理。

graph TB U["用户请求"] --> R["SkillRuntime"] R --> L["SkillLoader"] R --> P["Policy Resolver"] R --> M["MCP Tool Resolver"] R --> A["Eino Agent Factory"] M --> T["MCP to Eino Tool Adapters"] T --> G["Tool Guard"] T --> C["MCP Client"] A --> E["Eino Agent Loop"] E --> CM["ChatModel"] E --> T R --> O["Trace Recorder"] E --> O T --> O

边界要拆清楚:

层级 职责 应避免
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 指令和基础元数据:

markdown
---
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。

再把工具权限放到执行引擎自己的运行时策略文件里:

yaml
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

这样做有三个收益:

  1. 模型看到的工具集合更小、更相关。
  2. 运行时拥有确定性的策略边界,同时不把非标准字段伪装成 Skill 标准。
  3. 评审者可以把工具权限和指令文本分开审计。

MCP 输入参数建议使用严格 JSON Schema。设计和调试 schema 时,可以使用 JSON FormatterJSON Schema Generator,在运行前发现参数结构问题。


MCP 到 Eino Tool 的适配器

关键实现是一个 adapter:把 MCP descriptor 转换成 Eino 可识别的 Tool。

go
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:

go
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。这样动态状态是显式的。

go
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 应该能看到完整链路:

text
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 接入:

go
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 mcpskilllocalretriever
mcp.server MCP Server 身份
mcp.tool MCP Tool 身份
args_hash 不记录明文参数也能定位重复调用
output_bytes 发现异常大的工具返回
retry_count 区分依赖抖动和模型行为问题

默认不要把完整 prompt、用户输入、MCP 参数或 MCP 返回写入 trace。优先记录 hash、size 和 schema version。确实需要完整 payload 时,用受控采样、权限控制和短保留周期。


权限守卫

Prompt 指令不是安全边界。运行时必须用代码强制执行策略。

go
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。

go
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_depth
  • max_total_tool_calls
  • allowed_child_skills
  • 环路检测
  • 输出大小限制
  • 继承父 trace context

Skill 嵌套适合做任务拆分,但没有限制时很容易产生递归循环和不可预测成本。


测试策略

建议分层测试:

层级 测试目标 验证内容
单测 Runtime policy parser 必填字段、非法 allowlist、默认 limits
单测 MCP adapter Schema 转换、Guard 拒绝、timeout、错误映射
单测 Prompt renderer Metadata 注入和 prompt 稳定性
集成测试 Fake MCP server tools/listtools/call、异常响应
Agent 测试 Eino Agent + mock model 工具选择和循环终止
Trace 测试 OTel exporter Span 名称、父子关系、attribute
Eval 真实模型场景 成功率、无用工具调用、拒绝行为

策略单测示例:

go
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:

yaml
- 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 对象,包含 statusdataerror_codemessage 等字段。

所有边界都要 Trace。 至少记录 Skill load、prompt render、LLM call、MCP call、child Skill call 和 final output。

敏感输入只记录 hash。args_hashinput_hashoutput_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_stepsmax_tool_calls、单工具 timeout、输出大小限制和 token budget。把 token usage 和 tool count 写入 trace attribute。

嵌套 Skill 安全吗?

只有在有深度限制、allowlist、环路检测和共享 quota 时才安全。否则很容易递归或产生不可预测成本。


相关资源