Go 中的 LLM 工具調用:加入防護措施與成本控制的 ReAct 風格智能體循環
LLM Tool Calling in Go: A ReAct-Style Agent Loop with Guardrails and Cost Control
這篇 Go 實作指南展示如何構建採用 ReAct 模式的 LLM 工具調用智能體循環,並將執行防護與成本限制納入設計。設計包括不依賴特定供應商的模型介面、具型別的工具註冊表,以及步數、總 token、重複呼叫和執行時間限制;寫入工具須經批准,工具輸出亦會截短。
Most of my agent projects in Go share one piece of code: the loop. The model looks at the conversation, decides to call a tool or answer, the program runs the tool, feeds the result back, and repeats. That's the ReAct pattern (reason, act, observe) in its practical form.
I've built this loop several times: in agent-helper, a shared Go library that runs the same chat loop on Gemini or local Ollama models with an installable tool registry; in a Gemini tool-calling assistant for a Jira-style product; in a Clean Architecture ReAct agent that drafts social posts; and in a local voice assistant that uses Ollama tool calling. The happy path is about 40 lines. Most of the engineering is in the guardrails. This post builds a simplified version of that loop with the guardrails included.
Step 1: a provider-agnostic interface
Don't let the agent loop import a specific SDK. Define the minimum it needs:
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleTool Role = "tool"
)
type ToolCall struct {
ID string
Name string
Args json.RawMessage
}
type Message struct {
Role Role
Content string
ToolCalls []ToolCall // set when the model asks for tools
ToolCallID string // set on tool results
}
type Usage struct{ InputTokens, OutputTokens int }
type ChatModel interface {
Chat(ctx context.Context, msgs []Message, tools []ToolSpec) (Message, Usage, error)
}
Each provider (Gemini, OpenAI-compatible APIs, Ollama) gets a small adapter that converts to and from these types. Switching between a hosted model and a local one then becomes a config change, which is exactly how agent-helper moves the same agent between Gemini and Ollama.
Step 2: a typed tool registry
Tools are plug-ins. The loop shouldn't change when you add one.
type ToolSpec struct {
Name string
Description string
Schema json.RawMessage // JSON Schema for the arguments
}
type Tool struct {
Spec ToolSpec
Mutates bool // changes external state?
Timeout time.Duration // per-call limit
Run func(ctx context.Context, args json.RawMessage) (string, error)
}
type Registry struct{ tools map[string]Tool }
func (r *Registry) Register(t Tool) {
if r.tools == nil {
r.tools = map[string]Tool{}
}
r.tools[t.Spec.Name] = t
}
func (r *Registry) Specs() []ToolSpec {
specs := make([]ToolSpec, 0, len(r.tools))
for _, t := range r.tools {
specs = append(specs, t.Spec)
}
sort.Slice(specs, func(i, j int) bool { return specs[i].Name < specs[j].Name })
return specs
}
Sorting the specs keeps the prompt stable between calls, which helps reproducibility and, with providers that cache prompt prefixes, cost.
Inside Run, decode arguments strictly into a struct and validate them. The model's JSON is untrusted input:
func decodeArgs[T any](raw json.RawMessage) (T, error) {
var v T
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, fmt.Errorf("invalid arguments: %w", err)
}
return v, nil
}
Step 3: the loop, with budgets
Here's the core. Every guardrail is explicit:
type Limits struct {
MaxSteps int // model round-trips
MaxTotalTokens int // input + output across the run
MaxRepeats int // identical tool calls allowed
RunTimeout time.Duration // wall clock for the whole run
}
type Approver func(ctx context.Context, call ToolCall) (bool, error)
type Agent struct {
Model ChatModel
Tools *Registry
Limits Limits
Approve Approver // nil means write tools are refused
}
var ErrBudget = errors.New("agent budget exhausted")
func (a *Agent) Run(ctx context.Context, msgs []Message) (string, error) {
ctx, cancel := context.WithTimeout(ctx, a.Limits.RunTimeout)
defer cancel()
used := 0
seen := map[string]int{}
for step := 0; step < a.Limits.MaxSteps; step++ {
reply, usage, err := a.Model.Chat(ctx, msgs, a.Tools.Specs())
if err != nil {
return "", fmt.Errorf("step %d: %w", step, err)
}
used += usage.InputTokens + usage.OutputTokens
msgs = append(msgs, reply)
if len(reply.ToolCalls) == 0 {
return reply.Content, nil // final answer
}
if used > a.Limits.MaxTotalTokens {
return "", fmt.Errorf("%w: %d tokens", ErrBudget, used)
}
for _, call := range reply.ToolCalls {
key := call.Name + string(call.Args)
seen[key]++
var result string
if seen[key] > a.Limits.MaxRepeats {
result = "error: you already made this exact call; use the earlier result or try a different approach"
} else {
result = a.execute(ctx, call)
}
msgs = append(msgs, Message{Role: RoleTool, ToolCallID: call.ID, Content: result})
}
}
return "", fmt.Errorf("%w: max steps (%d)", ErrBudget, a.Limits.MaxSteps)
}
What each guardrail prevents:
- MaxSteps stops a model that never decides it's done.
- MaxTotalTokens caps cost per run. Every step resends the whole conversation, so input tokens grow with each round-trip. A ten-step run costs much more than ten times a single call.
- MaxRepeats catches the common failure where a model calls the same tool with the same arguments again and again.
-
RunTimeout bounds wall-clock time, and because it's a
context, it cancels in-flight HTTP calls and tools too.
Step 4: executing tools safely
func (a *Agent) execute(ctx context.Context, call ToolCall) string {
tool, ok := a.Tools.tools[call.Name]
if !ok {
return fmt.Sprintf("error: unknown tool %q", call.Name)
}
if tool.Mutates {
if a.Approve == nil {
return "error: this tool changes data and is not allowed in this session"
}
ok, err := a.Approve(ctx, call)
if err != nil || !ok {
return "error: the user did not approve this action"
}
}
tctx, cancel := context.WithTimeout(ctx, tool.Timeout)
defer cancel()
out, err := tool.Run(tctx, call.Args)
if err != nil {
return "error: " + err.Error()
}
const maxResult = 8_000
if len(out) > maxResult {
out = out[:maxResult] + "\n[truncated]"
}
return out
}
Three details matter here:
-
Errors go back to the model as text, not up the stack. A model that sees "invalid arguments: unknown field
prio" usually fixes the call on the next step. Only infrastructure failures (the model API itself failing) should end the run. -
Write tools need approval. The
Approvercan be a CLI prompt, a Telegram button or a web confirmation. Read tools run freely; anything that sends, deletes or updates waits for a human or a policy check. - Truncate tool output. One tool returning a 2 MB web page will blow your token budget and dilute the model's attention. Cap results, or summarise them before they go back.
Cost control beyond the loop
The loop's budgets are the safety net. These keep costs low in normal operation:
- Expose fewer tools. Every tool schema is sent on every step. Register only the tools the current task needs.
- Keep tool results compact. Return the fields the model needs, not whole database rows.
- Use smaller or local models where possible. With a provider-agnostic interface, routine steps can run on a local Ollama model and harder ones on a hosted model.
- Log usage per run. Record steps, tokens and tool calls for each run. You can't control a cost you don't measure.
Testing the loop
Because ChatModel is an interface, a scripted fake makes the loop fully testable without network calls:
type scriptedModel struct{ replies []Message; i int }
func (m *scriptedModel) Chat(ctx context.Context, _ []Message, _ []ToolSpec) (Message, Usage, error) {
r := m.replies[m.i]
m.i++
return r, Usage{InputTokens: 100, OutputTokens: 20}, nil
}
Write tests for the boring cases: unknown tool, invalid arguments, repeated call, budget exceeded, write tool without approval. Those are the paths that protect you in production.
Takeaways
The model decides what to try. Your Go code decides what is allowed, how long it may take and how much it may cost. Keep the loop small, keep the tools typed, and make every limit explicit.
Pradip Parmar is a Go backend and AI agent engineer who has worked in Go since 2018. He builds LLM agents with tool calling, MCP servers and RAG systems for businesses, from hosted Gemini and OpenAI-compatible models to fully local Ollama setups. To hire a Go AI agent developer, find him on LinkedIn, Upwork or Contra, and on GitHub.
來源:DEV Community · dev.to