// Created At 2026-09-08
// Go · Agent · LLM

Service & Migration: Turning the Agent into a Streaming API You Can Move to the Cloud (Part 4)

This part does two things: it turns the loop from Part 3 into a long-running HTTP service (browsers/clients receive tokens in real time over SSE), and it makes the same code switch to an OpenAI-compatible cloud endpoint by changing one OLLAMA_BASE — putting Part 2’s “/v1 is approximately compatible” iron rules into code.

  • Prerequisite: the environment checklist at the end of Part 1 is all green, and the multi-round loop from Part 3 already works
  • Requirements: Go 1.22+ (measured here on Go 1.27.1; the "GET /health" method-routing syntax used by this part’s server has been available since Go 1.22, which is where the floor comes from)

What changed since Part 3 (read this table before the code):

Change Where Why
New client (baseURL/apiKey/model/http), all driven by environment variables newClient One OpenAI-compatible client; point OLLAMA_BASE at the cloud to switch (§6)
chat() → streamChat(): line-by-line SSE parsing, tool_calls accumulated by index, callback push streamChat Streaming (§1, §3); fragmented arguments must be concatenated
Output goes from fmt.Printf to an emitter interface (eventSink / sseWriter) emitter + both impls The same loop can collect events (non-streaming JSON) or push them live (SSE)
New HTTP service: GET /health, POST /chat (streaming and non-streaming paths) main / handleChat Turns a one-shot CLI run into a long-running service (§2)
runAgent emits events instead of printing, and sseWriter gains Flush() plus multi-line data: splitting runAgent / sseWriter Lets the browser render as tokens arrive; newlines inside data cannot go on one line (§3)
handleChat → runAgent → streamChat thread context.Context, switching to http.NewRequestWithContext three signatures + handleChat A browser disconnect cancels the upstream generation instead of leaving the GPU spinning (§2)
The request body goes back to a ChatRequest struct (same shape as Parts 2/3/5) streamChat (plus a struct declaration) One style across the series, so readers do not wonder whether the server does something special
trimHistory removed — Server-side history currently lives only inside one request; for cross-request sessions see Part 3 §4

Unchanged from Part 3 and worth skimming past: the tool registry (toolEntry/binaryOp/tools/toolDefs), the loop skeleton, the “tool_calls non-empty” criterion, and the role="tool" feedback shape.

1. Real messages first: how the two streaming formats differ (captured on this machine)

For the same “call a tool” request, Ollama’s /v1 (OpenAI format) and the native /api/chat (ndjson) streaming messages are not the same:

/v1/chat/completions + "stream":true (each line is data: {...}, ending with data: [DONE]):

data: {"id":"chatcmpl-876",...,"choices":[{"index":0,"delta":{"role":"assistant","content":"","tool_calls":[{"id":"call_0sfy5hbq","index":0,"type":"function","function":{"name":"get_current_time","arguments":"{}"}}]},"finish_reason":null}]}

data: {"id":"chatcmpl-876",...,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}

data: [DONE]

/api/chat + "stream":true (one JSON per line, no data: prefix and no [DONE]):

{"model":"llama3.1:8b","created_at":"2026-09-07T14:09:28.37Z","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_6dem57qn","function":{"index":0,"name":"get_current_time","arguments":{}}}]},"done":false}
{"model":"llama3.1:8b","created_at":"2026-09-07T14:09:28.40Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","eval_count":14,...}

Four key differences:

Difference /v1 (OpenAI format) /api/chat (native)
Message wrapping data: {json}, ending with data: [DONE] Bare ndjson, no [DONE]; the end is done:true
End marker for a tool call finish_reason:"tool_calls" done_reason:"stop" (even when a tool was called)
Shape of arguments A JSON string ("{}") A JSON object ({})
Compatibility Matches OpenAI, so you can move to the cloud Ollama only

Conclusion: if you want “one codebase that can move to the cloud”, standardize on the /v1 format internally; the native /api/chat only suits setups that will never leave Ollama. The service in this part therefore implements just one OpenAI-compatible client.

2. Architecture: one conversation = one SSE event stream

client ──POST /chat──>  Go service ──/v1/chat/completions stream──> Ollama / cloud
       <── SSE event stream ──   (multi-round loop: model output and tool calls pushed interleaved)

Event types (the event: field):

Event Meaning
delta One token of model output (the final answer appears character by character)
round A new round begins; the model requested N tools
tool A tool execution result (including the error text fed back)
answer The final answer has been assembled
error / done An exception / the end of the session

The full sequence of one conversation (round/tool events arrive first, delta after):

Browser               Go service (this program)         Ollama / cloud
  │  POST /chat          │                                  │
  ├──────────────────────▶│  POST /v1/chat/completions       │
  │                       │  (stream:true, tools, history)   │
  │                       ├─────────────────────────────────▶│
  │                       │  ◀── data: delta.tool_calls ─────│  accumulate by index, concatenate arguments
  │  ◀── event: round ────│      (decide on non-empty tool_calls once the round is complete)
  │  ◀── event: tool ×N ──│  run tools locally → append role="tool" (ids aligned)
  │                       │  send again (still the full history) ─▶│
  │  ◀── event: delta ────│  ◀── data: delta.content ────────│  Flush() every token immediately
  │  ◀── event: answer ───│      (this round had no tool_calls, so it is the final answer)
  │  ◀── event: done ─────│                                  │

The server keeps no session: history is a local variable inside handleChat and dies when the request ends — so the browser has to resend the history every round (or the server must store it, see §8).

The browser side only needs to fetch and read the SSE line by line; testing with curl looks like this (see the real output in Section 5).

The minimum you need to know about SSE — three points, and that is enough for the code below: ① one event = an event: type line + one or more data: lines + a blank line to finish it; clients read line by line and close the event at that blank line (SSE = Server-Sent Events, the browser-native server push); ② the server must Flush() after every event, otherwise the first tokens sit in a buffer and the browser waits; ③ the connection stays open and is closed by the client or a timeout — heartbeats are still not implemented, see Section 8 for the production differences.

Cancellation and timeouts (implemented in this part’s code): handleChat threads r.Context() all the way down through runAgent → streamChat → http.NewRequestWithContext. So the moment the browser disconnects, the upstream generation request is cancelled and stops burning GPU time. http.Client.Timeout (5 minutes) caps how long a call may take; ctx answers whether the caller still wants it — different jobs, both required. This is also idiomatic Go: whenever a function makes a downstream call, thread ctx through as its first parameter.

3. The three streaming details that break most easily (the code already handles all three)

① delta.tool_calls’ arguments may arrive in fragments (the code concatenates them with strings.Builder, Go’s recommended way to build strings without reallocating on every +). When streaming from the OpenAI cloud, the argument JSON of a single tool call can be split across several chunks, and the client has to reassemble them by index; measured locally, Ollama sends it in one piece (see the capture in Section 1), but the code is written to expect fragments, so it works against both:

if tc.Function.Arguments != "" {
    p.arguments.WriteString(tc.Function.Arguments) // fragments must be concatenated
}

② content may be the empty string, and finish_reason only shows up on the last line. To decide “does this round need to execute tools?”, always wait until a whole round has been accumulated and go by whether tool_calls is non-empty (finish_reason is only a cross-check — some upstreams still report stop while calling tools, and trusting it alone drops the tool calls) — never conclude from the first chunk you receive.

③ A single line can exceed bufio.Scanner’s 64KB default. The code’s sc.Buffer(make([]byte, 1024), 1<<20) raises the per-line limit to 1MB — a single SSE line (a whole data: payload) can be long (a long arguments or a long delta), and without this it reports token too long and kills the entire stream. Of the three, this one is the easiest to reproduce and the easiest to overlook.

4. Full code

Full source of main.go (click to expand)
// Part 4 demo: turning the agent loop into a long-running HTTP service (SSE streaming, cloud-switchable).
// Differences from Part 3 are in the article's "what changed" table; the core is client
// (OLLAMA_BASE switches local/cloud), streamChat (accumulating fragmented arguments by index)
// and server (turning loop output into SSE events).
//
// Run:
//
//	OLLAMA_BASE="http://localhost:11434/v1" OLLAMA_MODEL=llama3.1:8b go run main.go
//	Listens on :8899 by default.
package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"time"
)

// ===========================================
// Message model (matching /v1/chat/completions)
// ===========================================

type Message struct {
	Role       string     `json:"role"`
	Content    string     `json:"content,omitempty"`
	ToolCalls  []ToolCall `json:"tool_calls,omitempty"`
	ToolCallID string     `json:"tool_call_id,omitempty"`
}

type ToolCall struct {
	ID       string       `json:"id"`
	Type     string       `json:"type"`
	Function FunctionCall `json:"function"`
}

type Tool struct {
	Type     string      `json:"type"`
	Function FunctionDef `json:"function"`
}

type FunctionDef struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Parameters  map[string]any `json:"parameters"`
}

// FunctionCall is explained in Part 2 §3 (tool_calls[].function)
type FunctionCall struct {
	Name      string `json:"name"`
	Arguments string `json:"arguments"`
}

// ChatRequest matches Part 3's (every part is a self-contained program, so it is
// declared again here). A struct rather than an inline map: Parts 2/3/5 all use the
// struct, so readers do not have to wonder whether the server does something special.
type ChatRequest struct {
	Model       string    `json:"model"`
	Messages    []Message `json:"messages"`
	Tools       []Tool    `json:"tools,omitempty"`
	Stream      bool      `json:"stream"`
	Temperature float64   `json:"temperature"`
}

// ===========================================
// Structures for parsing streaming chunks
// ===========================================

type streamChunk struct {
	Choices []struct {
		Delta struct {
			Role      string `json:"role"`
			Content   string `json:"content"`
			ToolCalls []struct {
				Index    int          `json:"index"`
				ID       string       `json:"id"`
				Type     string       `json:"type"`
				Function FunctionCall `json:"function"`
			} `json:"tool_calls"`
		} `json:"delta"`
		FinishReason string `json:"finish_reason"`
	} `json:"choices"`
}

// pendingCall accumulates one tool call that has not finished arriving (aligned by index).
type pendingCall struct {
	index     int
	id        string
	name      string
	arguments strings.Builder
}

// ===========================================
// Tool registry (same as Part 3, comments omitted)
// ===========================================

type toolEntry struct {
	description string
	parameters  map[string]any
	run         func(args json.RawMessage) (string, error)
}

// binaryOp factors out the pattern shared by the three arithmetic tools: parse the integer
// arguments {a,b} → run the operation → return the result. Each tool only passes in a one-line
// operation (e.g. func(a,b int)(int,error){ return a+b, nil }).
func binaryOp(desc string, fn func(int, int) (int, error)) *toolEntry {
	return &toolEntry{
		description: desc,
		parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"a": map[string]any{"type": "integer"},
				"b": map[string]any{"type": "integer"},
			},
			"required": []string{"a", "b"},
		},
		run: func(args json.RawMessage) (string, error) {
			var p struct{ A, B int }
			if err := json.Unmarshal(args, &p); err != nil {
				return "", fmt.Errorf("参数解析失败(应为 {\"a\":整数,\"b\":整数}):%v", err)
			}
			v, err := fn(p.A, p.B)
			if err != nil {
				return "", err
			}
			return fmt.Sprintf("%d", v), nil
		},
	}
}

var tools = map[string]*toolEntry{
	"get_current_time": {
		description: "获取当前的日期和时间",
		parameters:  map[string]any{"type": "object", "properties": map[string]any{}},
		run: func(args json.RawMessage) (string, error) {
			return time.Now().Format("2006-01-02 15:04:05"), nil
		},
	},
	"add": binaryOp("计算两个整数 a 与 b 的和",
		func(a, b int) (int, error) { return a + b, nil }),
	"multiply": binaryOp("计算两个整数 a 与 b 的乘积",
		func(a, b int) (int, error) { return a * b, nil }),
	"divide": binaryOp("计算两个整数 a 除以 b 的商(整除)",
		func(a, b int) (int, error) {
			if b == 0 {
				return 0, errors.New("除数不能为 0")
			}
			return a / b, nil
		}),
}

// toolDefs is identical to Part 3's (sorted by name for a stable request body; see Part 3 §2).
func toolDefs() []Tool {
	names := make([]string, 0, len(tools))
	for name := range tools {
		names = append(names, name)
	}
	sort.Strings(names)

	out := make([]Tool, 0, len(names))
	for _, name := range names {
		t := tools[name]
		var td Tool
		td.Type = "function"
		td.Function.Name = name
		td.Function.Description = t.description
		td.Function.Parameters = t.parameters
		out = append(out, td)
	}
	return out
}

// ===========================================
// OpenAI-compatible client (OLLAMA_BASE switches local/cloud)
// ===========================================

type client struct {
	baseURL string // e.g. http://localhost:11434/v1 or https://api.openai.com/v1
	apiKey  string
	model   string
	http    *http.Client
}

func newClient() *client {
	base := os.Getenv("OLLAMA_BASE")
	if base == "" {
		base = "http://localhost:11434/v1"
	}
	key := os.Getenv("OLLAMA_API_KEY")
	if key == "" {
		key = "ollama" // Ollama ignores the key; OpenAI needs a real one
	}
	model := os.Getenv("OLLAMA_MODEL")
	if model == "" {
		model = "llama3.1:8b"
	}
	return &client{baseURL: base, apiKey: key, model: model,
		http: &http.Client{Timeout: 5 * time.Minute}}
}

// streamChat sends one streaming request and accumulates the model output into
// a single assistant message.
// callback pushes every token downstream (SSE) in real time.
// ctx is threaded down from handleChat: when the client disconnects or its request
// times out, the upstream call is cancelled with it (http.Client.Timeout caps how
// long, ctx answers whether the caller still wants it).
func (c *client) streamChat(ctx context.Context, messages []Message, onDelta func(string)) (Message, string, error) {
	body, _ := json.Marshal(ChatRequest{
		Model:       c.model,
		Messages:    messages,
		Tools:       toolDefs(),
		Stream:      true,
		Temperature: 0, // greedy decoding: keeps the recorded output reproducible; use 0.7 for chat
	})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		return Message{}, "", err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.apiKey)

	resp, err := c.http.Do(req)
	if err != nil {
		return Message{}, "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return Message{}, "", fmt.Errorf("upstream %d: %s", resp.StatusCode, string(b))
	}

	var (
		acc       strings.Builder // accumulates this round's content (the final answer)
		calls     []*pendingCall  // accumulates this round's tool_calls (by index)
		finish    string
		callByIdx = map[int]*pendingCall{}
	)

	sc := bufio.NewScanner(resp.Body)
	sc.Buffer(make([]byte, 1024), 1<<20)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if !strings.HasPrefix(line, "data:") {
			continue
		}
		data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
		if data == "[DONE]" {
			break
		}
		var ch streamChunk
		if err := json.Unmarshal([]byte(data), &ch); err != nil {
			continue // ignore lines that cannot be parsed (compatibility guard)
		}
		for _, choice := range ch.Choices {
			if choice.FinishReason != "" {
				finish = choice.FinishReason
			}
			d := choice.Delta
			if d.Content != "" {
				acc.WriteString(d.Content)
				onDelta(d.Content)
			}
			for _, tc := range d.ToolCalls {
				p := callByIdx[tc.Index]
				if p == nil {
					p = &pendingCall{index: tc.Index}
					callByIdx[tc.Index] = p
					calls = append(calls, p)
				}
				if tc.ID != "" {
					p.id = tc.ID
				}
				if tc.Function.Name != "" {
					p.name = tc.Function.Name // with fragmented names this should append; the simple version takes it as-is
				}
				p.arguments.WriteString(tc.Function.Arguments) // arguments may be split across fragments and must be concatenated
			}
		}
	}

	// Check whether the Scanner has an error after the loop ends
	if err := sc.Err(); err != nil {
		return Message{}, "", fmt.Errorf("failed to read stream: %w", err)
	}

	msg := Message{Role: "assistant", Content: acc.String()}
	for _, p := range calls {
		var tc ToolCall
		tc.ID = p.id
		tc.Type = "function"
		tc.Function.Name = p.name
		tc.Function.Arguments = p.arguments.String()
		msg.ToolCalls = append(msg.ToolCalls, tc)
	}
	return msg, finish, nil
}

// ===========================================
// HTTP service
// ===========================================

type server struct {
	client *client
}

func main() {
	s := &server{client: newClient()}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]string{"status": "ok", "model": s.client.model})
	})
	mux.HandleFunc("POST /chat", s.handleChat)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8899"
	}
	fmt.Printf("stream-server 已启动: http://localhost:%s  (base=%s model=%s)\n",
		port, s.client.baseURL, s.client.model)
	if err := http.ListenAndServe(":"+port, mux); err != nil {
		fmt.Println("启动失败:", err)
	}
}

// handleChat: the multi-round agent loop plus real-time SSE pushes.
func (s *server) handleChat(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Messages []Message `json:"messages"`
		Stream   bool      `json:"stream"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
		return
	}

	if req.Stream {
		s.runAgentSSE(r.Context(), w, req.Messages)
		return
	}
	// Non-streaming: run the same logic, collect the events in memory, return JSON at the end
	events := &eventSink{}
	s.runAgent(r.Context(), events, req.Messages)
	answer, _ := events.lastAnswer()
	json.NewEncoder(w).Encode(map[string]any{"answer": answer, "events": events.list})
}

type event struct {
	Type string `json:"type"`
	Data string `json:"data"`
}

// eventSink collects events (used by the non-streaming path).
type eventSink struct{ list []event }

func (e *eventSink) emit(t, d string) { e.list = append(e.list, event{t, d}) }
func (e *eventSink) lastAnswer() (string, bool) {
	for i := len(e.list) - 1; i >= 0; i-- {
		if e.list[i].Type == "answer" {
			return e.list[i].Data, true
		}
	}
	return "", false
}

// sseWriter writes events straight to the browser (used by the streaming path).
type sseWriter struct {
	w http.ResponseWriter
	f http.Flusher
}

// emit writes one SSE event. data may contain newlines (the model's paragraph
// and blank-line tokens), so it must be split into several data: lines: in the
// SSE spec consecutive data lines are rejoined with \n, which keeps the frame
// legal while preserving newlines exactly (writing \n directly into a single
// data line would let a blank line terminate the event early and the client
// would drop the bare lines that follow).
func (s *sseWriter) emit(t, d string) {
	fmt.Fprintf(s.w, "event: %s\n", t)
	for _, line := range strings.Split(d, "\n") {
		fmt.Fprintf(s.w, "data: %s\n", line)
	}
	fmt.Fprint(s.w, "\n")
	s.f.Flush()
}

// emitter is the shared interface of both output targets.
type emitter interface{ emit(typ, data string) }

// runAgent is the core multi-round loop: model output is pushed token by token,
// and tool results are pushed once the tool has run.
func (s *server) runAgent(ctx context.Context, em emitter, history []Message) {
	const maxRounds = 8
	for round := 1; round <= maxRounds; round++ {
		msg, finish, err := s.client.streamChat(ctx, history, func(token string) {
			em.emit("delta", token) // push every token in real time
		})
		if err != nil {
			em.emit("error", err.Error())
			return
		}
		history = append(history, msg)

		// Iron rule: go by tool_calls being non-empty (same as Parts 2 and 3).
		// finish_reason is only a cross-check: some upstreams still report "stop"
		// (or omit it) while requesting tools, and trusting it alone silently
		// drops the tool calls.
		if len(msg.ToolCalls) == 0 {
			em.emit("answer", msg.Content)
			return
		}
		roundMsg := fmt.Sprintf("第 %d 轮:模型请求 %d 个工具", round, len(msg.ToolCalls))
		if finish != "tool_calls" {
			roundMsg += fmt.Sprintf("(finish_reason=%q,仍按 tool_calls 执行)", finish)
		}
		em.emit("round", roundMsg)
		for _, tc := range msg.ToolCalls {
			result, err := runTool(tc)
			if err != nil {
				result = fmt.Sprintf("工具执行出错:%v。请修正参数后重试,或放弃这一步。", err)
			}
			em.emit("tool", fmt.Sprintf("%s(%s) -> %s", tc.Function.Name, tc.Function.Arguments, result))
			history = append(history, Message{Role: "tool", ToolCallID: tc.ID, Content: result})
		}
	}
	em.emit("error", "达到最大轮数")
}

func (s *server) runAgentSSE(ctx context.Context, w http.ResponseWriter, history []Message) {
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	f, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	sw := &sseWriter{w: w, f: f}
	s.runAgent(ctx, sw, history)
	sw.emit("done", "[DONE]")
}

func runTool(tc ToolCall) (string, error) {
	t, ok := tools[tc.Function.Name]
	if !ok {
		return "", fmt.Errorf("未知工具 %q(模型幻觉了工具名)", tc.Function.Name)
	}
	return t.run(json.RawMessage(tc.Function.Arguments))
}

5. Run results (Ollama 0.33.3 + llama3.1:8b, request pins temperature=0; re-run 2026-09-19)

The console output and prompts in this transcript are Chinese exactly as captured; substituting English prompts changes the wording, not the structure or the conclusions.

# Start (OLLAMA_BASE points at local Ollama; moving to the cloud only changes environment variables, see Section 6)
PORT=8899 go run main.go

GET /health:

{"model":"llama3.1:8b","status":"ok"}

One complete SSE conversation (the raw event stream as the browser sees it):

curl -sN -X POST http://localhost:8899/chat -H "Content-Type: application/json" \
  -d '{"stream":true,"messages":[{"role":"user","content":"现在几点了?顺便帮我算 7 乘以 8。"}]}'
event: round
data: 第 1 轮:模型请求 2 个工具

event: tool
data: get_current_time({}) -> 2026-09-19 14:59:28

event: tool
data: multiply({"a":7,"b":8}) -> 56

event: delta
data: 现在
event: delta
data: 是
event: delta
data: 202
event: delta
data: 6
event: delta
data: -
…
(26 delta events in this round; the rest are elided — one token each, arriving until the sentence is complete)

event: answer
data: 现在是 2026-09-19 14:59:28。7 乘以 8 等于 56。

event: done
data: [DONE]

The point: the tool-calling round (round/tool) arrives before the final answer, and the final answer’s tokens stream in character by character (delta), so a browser can render as it receives. The non-streaming path ("stream":false) returns the same result as JSON, which is convenient for debugging and tests.

6. Moving to the cloud: change environment variables, not code

The service only speaks the OpenAI-compatible protocol internally, so “local Ollama ↔ cloud” is just three environment variables:

# Local
OLLAMA_BASE="http://localhost:11434/v1" OLLAMA_API_KEY=ollama OLLAMA_MODEL=llama3.1:8b

# Cloud (measured: Xiaomi MiMo API, an OpenAI-compatible protocol)
OLLAMA_BASE="https://api.xiaomimimo.com/v1" OLLAMA_API_KEY=sk-xxx OLLAMA_MODEL=mimo-v2.5-pro

How Part 2’s compatibility iron rules land in this code (this part’s requests always carry tools, so the empty-tools rule does not apply here):

  • With no tools, omit the tools field — note this part’s requests always carry tools, so the empty tools: [] difference never arises here; if your tool set can be empty, either omit the field or self-test against the Part 2 checklist;
  • Tool calls are detected from tool_calls being non-empty, not from content alone (finish_reason is only a cross-check);
  • delta.content emptiness checks accept both "" and a missing field.

Cloud measurement (MiMo-v2.5-pro, 2026-09-08)

Note: the transcript below is the original 2026-09-08 record (that request did not pin temperature, and this endpoint can no longer be re-measured from here); it does not affect the point that the structure (round → tool → delta → answer → done) matches the local run.

The same code and the same curl request, with only the environment variables pointing at the MiMo cloud:

event: round
data: 第 1 轮:模型请求 2 个工具

event: tool
data: get_current_time({}) -> 2026-09-08 16:48:21

event: tool
data: multiply({"a": 7, "b": 8}) -> 56

…(the delta events are elided — one token at a time; note that `arguments` carries spaces here)

event: answer
data: 现在是 **2026年9月8日 16:48:21**。另外,**7 × 8 = 56**。还有其他需要帮忙的吗?😊

event: done
data: [DONE]

Compared with local Ollama: the event stream structure is exactly the same (round → tool → delta → answer → done) with zero code changes. The only subtle difference worth noting: MiMo sends arguments as a JSON object with spaces ("a": 7) while Ollama sends a compact string ("{}"), and json.Unmarshal handles both correctly — the defensive design in Section 3 covers exactly this kind of difference.

7. Pitfalls and comparisons

Symptom Cause Handling
SSE arrives in fits and starts / the client misses parts Not understanding event:/data:/blank-line separation Read line by line and end the current event at a blank line (see the raw stream in Section 5)
Half of a tool call’s arguments go missing Treating a fragmented arguments as complete JSON Accumulate by index, concatenate, then parse the whole thing
The model already called a tool but it is treated as an ordinary answer Returning after only the first chunk, or treating finish_reason as the sole criterion Accumulate the whole round, then go by tool_calls being non-empty (finish_reason is only a cross-check)
Works locally, behaves differently in the cloud /v1 is approximately compatible; the cloud is stricter about fields Already measured against the MiMo cloud (§6) with zero code changes; still worth running through the checklist before switching to another service
Long tasks time out / curl disconnects early No timeout budget on the upstream request, or proxy buffering is still on Raise http.Client.Timeout as needed; this part already threads r.Context() for cancellation (§2); with a proxy in front you still have to disable buffering

8. Deliberately simplified vs. production practice

Deliberately simplified What production does
No auth and no rate limiting on the service API keys / middleware / per-user quotas
Conversation history lives only inside one request Session isolation is discussed in Part 5; production-grade session storage and multi-tenancy are outside this series’ scope
One SSE connection runs the whole exchange A task queue plus progress events and reconnection
No heartbeats or reconnection SSE comment heartbeats (: ping) plus Last-Event-ID resumption
Four tools, hard-coded A plugin registry / configuration loading
Cloud migration already measured Still worth running through the compatibility checklist before switching to another OpenAI-compatible service

FAQ

Question Fix
The browser receives no stream The server must Flush(); confirm the text/event-stream response header
Want to compare the native /api/chat stream Use the capture command from Section 1 to compare for yourself; the service goes through /v1 uniformly by default
401 after switching to the cloud Check OLLAMA_API_KEY; local Ollama ignores the key

Conclusion

  1. Turning it into a service only adds a shell: the core is still the loop from Part 3, plus an HTTP layer forwarding SSE events;
  2. Streaming details decide success or failure: concatenating fragmented arguments, checking finish_reason on the whole round, and Flush() — all three are indispensable;
  3. Portability comes from converging on a protocol: depending only on an OpenAI-compatible subset means switching local/cloud is a matter of environment variables;
  4. The service still has no memory — session isolation, cross-session long-term memory and lightweight RAG are the subject of Part 5 (in-process demonstration form); production-grade session storage and multi-tenancy are outside this series’ scope.

Next up: “Memory: Multi-Session Facts and Lightweight RAG”.

If this article helped you, you can buy me a coffee ☕️
Ali PayWechat Pay