LLM Function Calling 工程化落地:从工具定义到异常容错的生产实践
LLM Function Calling 工程化落地:从工具定义到异常容错的生产实践
一、Function Calling 不是"加个参数"那么简单
Function Calling(工具调用)是 LLM 与大模型应用交互的核心能力。它让模型能够根据用户意图,选择调用预先定义的函数并提取参数。在 OpenAI 的 API 中,这表现为tools参数的声明;在实际工程落地中,这涉及工具定义、参数校验、执行调度和结果回传的完整链路。
然而,很多开发者在生产环境中遇到的问题是:模型选择了错误的函数、提取的参数格式不对、多次递归调用导致 Token 开销失控、函数执行异常时模型不知道如何处理。这些问题的根源在于,Function Calling 不是模型的一个"开关"而是一种"对话协议"——模型只负责按 schema 输出 JSON,剩下的参数校验、执行容错和状态管理,全部需要应用层来兜底。
本文从工具注册、执行引擎和容错机制三个维度,给出 Go 语言中的生产级 Function Calling 落地方案。
二、Function Calling 的完整调用链路
sequenceDiagram participant User as 用户 participant App as 应用层 participant LLM as LLM API participant Tool as 工具执行引擎 User->>App: 发起请求 App->>LLM: 请求 + tools 定义 LLM-->>App: 响应 (含 tool_calls) App->>App: 解析 tool_calls alt 没有 tool_calls App-->>User: 直接返回文本 else 有 tool_calls loop 每个 tool_call App->>Tool: 调用对应函数 Tool-->>App: 返回执行结果 end App->>LLM: 第二次请求 (消息历史 + tool 结果) LLM-->>App: 最终响应 App-->>User: 返回结果 end这条链路的关键在于第二次请求。第一次请求模型返回tool_calls,应用层执行完所有函数后,需要将执行结果拼接到消息历史中再次请求模型。模型根据这些结果生成自然语言回复。如果这个"执行结果 → 回传 → 二次生成"的拼装逻辑写错了,再好的 Function Calling 定义也无济于事。
三、Go 中的 Function Calling 引擎实现
3.1 工具定义与注册
package tools import ( "context" "encoding/json" "fmt" ) // ToolSchema 描述一个工具的函数签名,与 OpenAI tools 参数格式对齐。 type ToolSchema struct { Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` // JSON Schema } // Tool 是注册到引擎中的工具实例。 type Tool struct { Schema ToolSchema Handler func(ctx context.Context, args json.RawMessage) (interface{}, error) } // Registry 管理所有可用的工具。 type Registry struct { tools map[string]Tool } func NewRegistry() *Registry { return &Registry{tools: make(map[string]Tool)} } // Register 注册一个工具。 // 重复注册同名工具会 panic,以避免静默覆盖导致的线上问题。 func (r *Registry) Register(tool Tool) { if _, exists := r.tools[tool.Schema.Name]; exists { panic(fmt.Sprintf("tool %s already registered", tool.Schema.Name)) } r.tools[tool.Schema.Name] = tool } // GetOpenAITools 返回 OpenAI API 要求的 tools 参数格式。 func (r *Registry) GetOpenAITools() []map[string]interface{} { result := make([]map[string]interface{}, 0, len(r.tools)) for _, tool := range r.tools { result = append(result, map[string]interface{}{ "type": "function", "function": map[string]interface{}{ "name": tool.Schema.Name, "description": tool.Schema.Description, "parameters": tool.Schema.Parameters, }, }) } return result } // Execute 根据 tool_call 的名称和参数执行对应的工具。 func (r *Registry) Execute(ctx context.Context, name string, args json.RawMessage) (interface{}, error) { tool, ok := r.tools[name] if !ok { return nil, fmt.Errorf("unknown tool: %s", name) } return tool.Handler(ctx, args) }3.2 注册一个查询订单的工具
package main import ( "context" "encoding/json" "fmt" ) // GetOrderParams 定义 get_order 工具的参数 schema。 type GetOrderParams struct { OrderID string `json:"order_id"` } // RegisterOrderTool 注册订单查询工具。 func RegisterOrderTool(r *tools.Registry) { paramsSchema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "order_id": map[string]interface{}{ "type": "string", "description": "订单编号,如 ORD-2024-00123", }, }, "required": []string{"order_id"}, } paramsBytes, _ := json.Marshal(paramsSchema) r.Register(tools.Tool{ Schema: tools.ToolSchema{ Name: "get_order", Description: "根据订单编号查询订单详情,包含订单状态、金额和物流信息", Parameters: paramsBytes, }, Handler: func(ctx context.Context, args json.RawMessage) (interface{}, error) { var params GetOrderParams if err := json.Unmarshal(args, ¶ms); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } // 调用实际业务逻辑查询订单 return getOrderFromDB(ctx, params.OrderID) }, }) }3.3 Function Calling 执行引擎
执行引擎是整个系统的核心,负责管理多轮工具调用的调度和消息历史拼接。
package engine import ( "context" "encoding/json" "fmt" "log" "your/llmclient" "your/tools" ) // CallConfig 定义单次 Function Calling 调用的配置。 type CallConfig struct { Model string // 模型名称 Messages []llmclient.Message Tools *tools.Registry MaxTurns int // 最大调用轮次,防止无限循环 Temperature float64 } // Execute 执行一次完整的 Function Calling 流程。 // 可能包含多轮工具调用。 func Execute(ctx context.Context, cfg CallConfig) (string, error) { messages := make([]llmclient.Message, len(cfg.Messages)) copy(messages, cfg.Messages) for turn := 0; turn < cfg.MaxTurns; turn++ { // 1. 向 LLM 发送请求(包含当前消息历史和工具定义) resp, err := llmclient.Chat(ctx, llmclient.ChatRequest{ Model: cfg.Model, Messages: messages, Tools: cfg.Tools.GetOpenAITools(), Temperature: cfg.Temperature, }) if err != nil { return "", fmt.Errorf("llm call failed at turn %d: %w", turn, err) } // 2. 将模型的响应加入消息历史 messages = append(messages, llmclient.Message{ Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls, }) // 3. 检查是否有工具调用请求 if len(resp.ToolCalls) == 0 { // 模型直接返回文本,流程结束 return resp.Content, nil } // 4. 执行所有工具调用 for _, tc := range resp.ToolCalls { var args json.RawMessage = json.RawMessage(tc.Arguments) result, execErr := cfg.Tools.Execute(ctx, tc.Name, args) // 构造 tool 结果消息 var resultContent string if execErr != nil { resultContent = fmt.Sprintf("ERROR: %s", execErr.Error()) log.Printf("[FunctionCall] tool %s failed: %v", tc.Name, execErr) } else { resultBytes, _ := json.Marshal(result) resultContent = string(resultBytes) } messages = append(messages, llmclient.Message{ Role: "tool", ToolCallID: tc.ID, Content: resultContent, }) } } // 超过 MaxTurns 仍未结束 return "", fmt.Errorf("function calling exceeded max turns (%d)", cfg.MaxTurns) }3.4 完整的工具定义示例
package main import ( "encoding/json" "fmt" ) // RegisterWeatherTool 注册天气查询工具。 func RegisterWeatherTool(r *tools.Registry) { // 定义符合 JSON Schema 的参数结构 params := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "location": map[string]interface{}{ "type": "string", "description": "城市名称,如北京、上海、深圳", }, "date": map[string]interface{}{ "type": "string", "description": "日期,格式 YYYY-MM-DD,默认为今天", }, }, "required": []string{"location"}, } paramsBytes, _ := json.Marshal(params) r.Register(tools.Tool{ Schema: tools.ToolSchema{ Name: "query_weather", Description: "查询指定城市在指定日期的天气信息", Parameters: paramsBytes, }, Handler: func(ctx context.Context, args json.RawMessage) (interface{}, error) { var p struct { Location string `json:"location"` Date string `json:"date,omitempty"` } if err := json.Unmarshal(args, &p); err != nil { return nil, fmt.Errorf("invalid weather params: %w", err) } return queryWeather(p.Location, p.Date) }, }) } func queryWeather(location, date string) (interface{}, error) { // 调第三方天气 API return map[string]interface{}{ "location": location, "date": date, "temperature": 28, "condition": "晴", "humidity": 45, }, nil }四、Function Calling 的边界问题与容错策略
Function Calling 在生产中暴露最多的问题不是"模型选错函数",而是工程边界的缺失。
4.1 参数注入与校验
模型输出的参数是一个 JSON 字符串,它可能包含不符合预期的值。以下三种情况在生产中反复出现:
- 类型不匹配:模型可能输出
"order_id": 12345(整数)而非"order_id": "ORD-12345"(字符串) - 缺失必填字段:
required字段的约束并不总是被严格遵守 - 额外的未知字段:模型可能注入不在 schema 中的字段
解决方案:在工具 Handler 中做严格的反序列化+校验,拒绝不合法输入并返回错误信息给模型,让模型自行修正。
4.2 并发控制与限流
当 LLM 在一次响应中返回多个tool_calls时,这些调用在逻辑上是并行的。如果每个工具都访问同一个限流资源(如第三方 API),就会触发限流。
// ParallelExecutor 并发执行多个工具调用,但受限于令牌桶。 type ParallelExecutor struct { registry *tools.Registry limiter *rate.Limiter // 每秒最多 N 次调用 } func (pe *ParallelExecutor) ExecuteAll(ctx context.Context, calls []llmclient.ToolCall) []ToolResult { var wg sync.WaitGroup results := make([]ToolResult, len(calls)) for i, call := range calls { wg.Add(1) go func(idx int, tc llmclient.ToolCall) { defer wg.Done() // 等待令牌 pe.limiter.Wait(ctx) result, err := pe.registry.Execute(ctx, tc.Name, json.RawMessage(tc.Arguments)) results[idx] = ToolResult{Name: tc.Name, ID: tc.ID, Data: result, Err: err} }(i, call) } wg.Wait() return results }4.3 最大轮次保护
MaxTurns是必须设置的硬限制。如果没有这个限制,当模型持续返回tool_calls时,Token 消耗和延迟都会失控。经验值是 5-10 轮。超过后,应该将已执行的中间结果摘要返回给用户,而不是静默丢弃。
4.4 工具执行超时
单个工具的执行时间不应该超过 10 秒。超过即返回超时错误,模型会根据错误信息判断是否重试或跳过。
func timeoutHandler(timeout time.Duration, handler func(ctx context.Context, args json.RawMessage) (interface{}, error)) func(ctx context.Context, args json.RawMessage) (interface{}, error) { return func(ctx context.Context, args json.RawMessage) (interface{}, error) { runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() return handler(runCtx, args) } } // 使用方式: // r.Register(tools.Tool{ // Handler: timeoutHandler(5*time.Second, realHandler), // })五、总结
Function Calling 是打通 LLM 与实际业务系统的桥梁。工程落地的核心实践可以归纳为五条:
- 工具定义要精确:
name和description的措辞直接影响模型选择的准确性。描述应该包含何时调用、入参格式的约束和边界条件。 - 参数校验要严格:模型输出的 JSON 不一定合法,工具 Handler 必须对入参做反序列化校验,不合法时返回明确的错误信息。
- 并发执行要可控:多
tool_calls并行执行时,添加令牌桶限流和超时控制,避免下游系统被打爆。 - 通信次数要限制:设置
MaxTurns硬限制,避免无限循环。超出时返回摘要而非完整结果。 - 错误要回传而非静默吞掉:工具执行的错误信息应该以工具结果的形式回传给模型,让模型决定下一步是重试、跳过还是告知用户。
