Memory: Multi-Session Facts and Lightweight RAG (Part 5)
The multi-round loop of Part 3 made the agent able to get work done, but it starts every run with amnesia. This part adds two kinds of memory: cross-session facts (remember/recall key-value memory, persisted to JSON) and document retrieval (lightweight RAG doing vector search over local notes).
- Prerequisite: the environment checklist at the end of Part 1 is all green, and the multi-round loop from Part 3 already works
- Preparation:
ollama pull nomic-embed-text(about 274MB; without this model the example degrades to keyword retrieval automatically) - Shape note: this part keeps the one-shot command-line run of Part 3 (three sessions demonstrating “session isolation” in sequence inside one process;
go run main.go recallstarts a separate process to prove cross-process persistence) rather than extending Part 4’s SSE service; to wire memory into the service, move this tool table and loop into the server
What changed since Part 3 (read this table before the code):
| Change | Where | Why |
|---|---|---|
The tools are swapped wholesale: remember / recall / list_memory / search_notes |
tools |
One set of tools per memory kind: key-value facts plus document retrieval (§1, §2) |
New memoryStore reading and writing a local memory.json |
loadMemory / set |
Cross-session, cross-process facts (surviving process exit) |
New retriever interface with two implementations |
vectorRetriever / keywordRetriever |
Vector search when an embedding model exists, automatic keyword fallback when it does not |
New tokenize / top / cosine / embedTexts |
retrieval parts | The whole lightweight RAG implementation, about 60 lines (§3) |
runAgent(userPrompt) → runAgent(messages), plus three session functions |
main / runSessionA-C |
Demonstrate “session isolation” in one run; go run main.go recall demonstrates cross-process |
temperature=0 pinned |
chat() |
Keeps the measured output stable and reproducible |
trimHistory and the usage counters removed |
— | This part focuses on memory and retrieval; history growth is still Part 3 §4 |
Unchanged from Part 3 and worth skimming past: the
Message/ToolCall/Toolstructs, the loop skeleton, therole="tool"feedback and “tool_callsnon-empty” criterion, and thetoolEntryregistry shape.
1. There are three kinds of memory; don’t conflate them
| Kind | What it is | Where it lives | What this part does |
|---|---|---|---|
| In-session memory | The context of the current conversation | The messages slice in memory |
Used throughout the earlier parts |
| Long-term memory | Facts that outlive a session (user name, preferences) | External storage | Key-value pairs written to a local JSON file |
| Document memory | Local documents/notes that can be searched | A document store + an index | topK vector retrieval (lightweight RAG) |
This part adds the latter two kinds with two sets of tools (the agents in the earlier parts start with amnesia every time — clear messages and they remember nothing): remember/recall/list_memory (long-term facts) plus search_notes (document retrieval). (The “session isolation” this part refers to means: each session keeps its own messages slice, so A’s conversation history never appears in B — the only thing shared across sessions is the memory.json on disk.)
2. Long-term memory: key-value facts plus enumeration
Three details of the tool design decide whether memory is actually usable:
- Persistence:
rememberwrites key-value pairs intomemory.json, so a process restart loses nothing; - Namespace-style keys:
用户:名字(“user:name”),用户:语言(“user:language”), so that different subjects don’t collide on the same key; - A
list_memory(enumeration) tool is mandatory: key-value memory presupposes “knowing the key names”. In a new session the model has no idea which keys were written before, and guessing keys throughrecallalone fails (in our measurements it guessed用户:名字, and even passed*); once an enumeration tool is available, listing every pair is enough for it to answer.
3. Lightweight RAG: embed → cosine → topK
In plain words first: embedding turns a piece of text into a list of numbers (a vector) — the closer two texts are in meaning, the closer their vectors point, so “how alike are these two?” becomes something you can compute directly with cosine similarity.
The idea behind search_notes is the smallest possible RAG (Retrieval-Augmented Generation: retrieve the material first, then have the model answer from that material rather than from its own memory):
vectorize the question (query) and each note separately
→ compute the cosine similarity between the query and each note
→ keep the topK fragments with similarity >= 0.35
→ paste the fragments into the tool result so the model "answers from the notes"
What happens inside one search_notes call:
The model supplies a query
│
▼
Wrap it for the embedding model: the query gets "search_query: ", every note gets "search_document: "
│ (the prefixes are nomic-embed-text's own convention — follow them, but do not expect them alone to save you)
▼
POST /api/embed (every text in one request) ──▶ [vectors…]
▼
Cosine similarity per note ──▶ coarse filter at 0.35 ──▶ sort and take topK=3
▼
Build the tool result: a fixed "answer only from these fragments, invent nothing" header + [片段N 相似度X.XX] text
▼
Feed it back as role="tool" ──▶ the model answers from the fragments (measured output in §5, session C)
Fallback path: without
nomic-embed-textthe same interface scores by keyword matches (count of matched words), with an identical flow — only the score label changes from “相似度” to “命中词数”. The two scores are not comparable, so they must not share one label.
Two things here go against the usual intuition, so here are the measurements from this machine first (Ollama 0.33.3 + nomic-embed-text, over exactly the 6 notes above; the numbers are cosine similarities):
| Query | With prefixes (search_document: / search_query:) |
Without prefixes |
|---|---|---|
Intel Arc Ollama GPU 加速 (relevant) |
0.560 ~ 0.778 | 0.592 ~ 0.771 |
怎么做红烧肉? (completely unrelated) |
0.500 ~ 0.598 (all 6 clear 0.35) | 0.465 ~ 0.530 |
- The similarity threshold has to be calibrated against your own corpus; copying a 0.35 usually makes it a no-op: with short, topically similar text the cosine scores are squeezed into a narrow high band (0.5–0.8 here), so even a completely unrelated question scores above 0.5 and all 6 notes pass — what actually narrows things down to 3 is
topK. A threshold is not a bad design (scores only spread out once your corpus is more diverse or your documents are long), but it must be tuned for that corpus; this part keeps>= 0.35purely to demonstrate the shape of the code, so don’t copy that number. - The embedding prefix is the model’s own convention: follow it, but don’t expect it to solve retrieval on its own:
nomic-embed-textexpects documents prefixed withsearch_document:and queries withsearch_query:; measured here, with or without them the difference is small (top score 0.778 vs 0.771, essentially the same ranking), and it only becomes visible on a more diverse corpus. Compared with the prefix, threshold calibration, chunking and topK usually matter more.
4. Full code
Full source of main.go (click to expand)
// Part 5 demo: memory and lightweight RAG.
// Differences from Part 3 are in the article's "what changed" table; the core is memoryStore
// (JSON on disk), the retriever (vector search with a keyword fallback) and the four new tools.
//
// Run: go run main.go — runs sessions A / B / C in order
//
// go run main.go recall — runs session B alone (proves cross-process recall, see Section 5)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"sort"
"strings"
"time"
)
// ===========================================
// Structures 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"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Stream bool `json:"stream"`
Temperature float64 `json:"temperature"`
}
type Choice struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type ChatResponse struct {
Choices []Choice `json:"choices"`
}
// ===========================================
// Memory store (cross-session, persisted to a file)
// ===========================================
type memoryStore struct {
path string
data map[string]string
}
func loadMemory(path string) *memoryStore {
m := &memoryStore{path: path, data: map[string]string{}}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &m.data)
}
return m
}
func (m *memoryStore) set(key, value string) error {
m.data[key] = value
b, _ := json.MarshalIndent(m.data, "", " ")
return os.WriteFile(m.path, b, 0o644)
}
// ===========================================
// Note store (standing in for "local material") and the retriever
// ===========================================
var notes = []string{
"Ollama 默认只识别 NVIDIA(CUDA) 与 AMD(ROCm) 显卡;Intel Arc 需要在 systemd override 里设置 OLLAMA_VULKAN=true 并重启服务,才会走 Vulkan 后端。",
"验证 Vulkan 是否生效:journalctl -u ollama --no-pager | grep 'inference compute',看到 library=Vulkan 与 Arc A770 的描述即为成功。",
"OLLAMA_LOAD_TIMEOUT 默认 5 分钟,控制模型加载停滞多久后放弃;驱动慢或首次编译着色器时可调大,例如 10m。",
"OLLAMA_KEEP_ALIVE 默认 5 分钟,控制模型空闲多久后卸载;频繁调用可调大到 10m 以减少重复加载。",
"Vulkan 后端在部分 Linux 内核 + Mesa 驱动下有显存记账失步、空闲显存被换出的已知问题,长时间运行要用 intel_gpu_top 或 journalctl 监控。",
"llama3.1:8b 在 Intel Arc A770 上实测 33/33 层全量 offload,模型占显存约 4.4GB,生成速度约 41 tokens/s。",
}
// retriever finds the most relevant note fragments for a query.
// metric() names what score means: cosine similarity for the vector path, matched-word
// count for the keyword fallback. The two are different units, so they must not share
// the "similarity" label when printed.
type retriever interface {
search(query string, topK int) []searchHit
metric() string
}
type searchHit struct {
text string
score float64
}
// vectorRetriever uses the vectors from /api/embed for cosine similarity.
// nomic-embed-text only performs well with the standard prefixes: documents get
// "search_document:" and queries get "search_query:" (the embedding model's own
// convention).
type vectorRetriever struct{}
func (vectorRetriever) metric() string { return "相似度" }
func (vectorRetriever) search(query string, topK int) []searchHit {
texts := make([]string, 0, len(notes)+1)
texts = append(texts, "search_query: "+query)
for _, n := range notes {
texts = append(texts, "search_document: "+n)
}
vecs, err := embedTexts(texts)
if err != nil {
fmt.Println(" ⚠️ 向量检索失败,本次降级为空结果:", err)
return nil
}
if len(vecs) != len(texts) { // avoid an out-of-range panic when the count does not match the input
fmt.Println(" ⚠️ 向量检索返回数量不符,本次降级为空结果")
return nil
}
q := vecs[0]
var hits []searchHit
for i, n := range notes {
s := cosine(q, vecs[i+1])
if s >= 0.35 { // too low a threshold drags unrelated fragments in as well
hits = append(hits, searchHit{n, s})
}
}
return top(hits, topK)
}
// keywordRetriever is the fallback when there is no embedding model: score by the number of matched words.
type keywordRetriever struct{}
func (keywordRetriever) metric() string { return "命中词数" }
func (keywordRetriever) search(query string, topK int) []searchHit {
words := tokenize(query)
var hits []searchHit
for _, n := range notes {
score := 0.0
lower := strings.ToLower(n) // case-insensitive for English (e.g. intel ↔ Intel)
for _, w := range words {
if strings.Contains(lower, w) {
score++
}
}
if score > 0 {
hits = append(hits, searchHit{n, score})
}
}
return top(hits, topK)
}
func tokenize(s string) []string {
// Chinese is split into 2-character sliding windows plus English split on
// whitespace, which is enough for a demo
var out []string
runes := []rune(s)
for i := 0; i+1 < len(runes); i++ {
if runes[i] < 128 && runes[i+1] < 128 {
continue
}
out = append(out, string(runes[i:i+2]))
}
for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') }) {
if len(w) > 1 {
out = append(out, strings.ToLower(w))
}
}
return out
}
func top(hits []searchHit, k int) []searchHit {
for i := 1; i < len(hits); i++ {
for j := i; j > 0 && hits[j].score > hits[j-1].score; j-- {
hits[j], hits[j-1] = hits[j-1], hits[j]
}
}
if len(hits) > k {
hits = hits[:k]
}
return hits
}
func cosine(a, b []float64) float64 {
if len(a) != len(b) { // different dimensions: return 0 instead of panicking on an out-of-range index
return 0
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// ===========================================
// Tool registration
// ===========================================
type toolEntry struct {
description string
parameters map[string]any
run func(args json.RawMessage) (string, error)
}
var (
mem = loadMemory("memory.json")
retrieve retriever = newRetriever()
)
func newRetriever() retriever {
// Probe /api/tags for an embedding model; use vectors if present, otherwise
// fall back to keywords. This HTTP call sits on the startup path, so it needs a
// timeout: a stuck Ollama must not stall startup (Part 2's "give every upstream
// request an explicit timeout budget" applies here too).
probe := &http.Client{Timeout: 3 * time.Second}
resp, err := probe.Get("http://localhost:11434/api/tags")
if err == nil {
defer resp.Body.Close()
var tags struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if json.NewDecoder(resp.Body).Decode(&tags) == nil {
for _, m := range tags.Models {
if strings.Contains(m.Name, "nomic-embed-text") {
fmt.Println("📚 检测到 nomic-embed-text,启用向量检索")
return vectorRetriever{}
}
}
}
}
fmt.Println("📚 未检测到 embedding 模型,降级为关键词检索(可 ollama pull nomic-embed-text 升级)")
return keywordRetriever{}
}
var tools = map[string]*toolEntry{
"remember": {
description: "把一条事实写入长期记忆(跨会话保留)。key 用命名空间风格,如 用户:名字、项目:目标",
parameters: map[string]any{"type": "object", "properties": map[string]any{
"key": map[string]any{"type": "string", "description": "记忆的键,如 用户:名字"},
"value": map[string]any{"type": "string", "description": "记忆的内容"},
}, "required": []string{"key", "value"}},
run: func(args json.RawMessage) (string, error) {
var p struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("参数解析失败:%v", err)
}
if err := mem.set(p.Key, p.Value); err != nil {
return "", err
}
return fmt.Sprintf("已记住 %s = %s", p.Key, p.Value), nil
},
},
"recall": {
description: "从长期记忆里读取一条事实。key 与 remember 写入时一致;不确定键名时先用 list_memory 列出",
parameters: map[string]any{"type": "object", "properties": map[string]any{
"key": map[string]any{"type": "string", "description": "记忆的键"},
}, "required": []string{"key"}},
run: func(args json.RawMessage) (string, error) {
var p struct {
Key string `json:"key"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("参数解析失败:%v", err)
}
if v, ok := mem.data[p.Key]; ok {
return v, nil
}
return fmt.Sprintf("长期记忆中没有 %s 的记录", p.Key), nil
},
},
"list_memory": {
description: "列出长期记忆里所有的键与值,用于不确定键名时查看",
parameters: map[string]any{"type": "object", "properties": map[string]any{}},
run: func(args json.RawMessage) (string, error) {
if len(mem.data) == 0 {
return "长期记忆为空", nil
}
// Sort the output: Go iterates maps in random order, so without sorting the
// key order changes from run to run (same reason as toolDefs: stable output
// is what makes a run reproducible and comparable)
keys := make([]string, 0, len(mem.data))
for k := range mem.data {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
fmt.Fprintf(&sb, "%s = %s\n", k, mem.data[k])
}
return strings.TrimSpace(sb.String()), nil
},
},
"search_notes": {
description: "检索本地运维笔记库,返回最相关的片段,用于回答需要查资料的问题",
parameters: map[string]any{"type": "object", "properties": map[string]any{
"query": map[string]any{"type": "string", "description": "要检索的问题或关键词"},
}, "required": []string{"query"}},
run: func(args json.RawMessage) (string, error) {
var p struct {
Query string `json:"query"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("参数解析失败:%v", err)
}
hits := retrieve.search(p.Query, 3)
if len(hits) == 0 {
return "笔记库中没有找到相关内容。", nil
}
var sb strings.Builder
sb.WriteString("(以下是检索到的笔记片段,请严格只依据这些内容回答,不要编造笔记里没有的细节)\n")
for i, h := range hits {
fmt.Fprintf(&sb, "[片段%d %s%.2f] %s\n", i+1, retrieve.metric(), h.score, h.text)
}
return strings.TrimSpace(sb.String()), 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
}
// ===========================================
// Sessions and the agent loop
// ===========================================
func main() {
// Cross-process persistence demo: run `go run main.go` once to write the memory
// (then the process exits), then `go run main.go recall` to run session B alone —
// nothing is shared but memory.json.
if len(os.Args) > 1 && os.Args[1] == "recall" {
fmt.Println("===== 会话 B(新进程):换一个全新会话(历史不共享),问它记得我吗 =====")
runSessionB()
return
}
fmt.Println("===== 会话 A:让 Agent 记住一些事实 =====")
runSessionA()
fmt.Println()
fmt.Println("===== 会话 B:换一个全新会话(历史不共享),问它记得我吗 =====")
runSessionB()
fmt.Println()
fmt.Println("===== 会话 C:查笔记回答问题(轻量 RAG)=====")
runSessionC()
}
func runSessionA() {
runAgent([]Message{{Role: "user", Content: "请调用两次 remember 工具:第一次 key=用户:名字、value=小明;第二次 key=用户:语言、value=Go。只做这两次调用,不要做其他事。"}})
}
func runSessionB() {
runAgent([]Message{{Role: "user", Content: "这是一个全新的会话。请调用 list_memory 一次把长期记忆全部列出来,然后直接根据列出的内容回答我:你记得关于我的什么?列完就不用再调用其他工具了。"}})
}
func runSessionC() {
runAgent([]Message{{Role: "user", Content: "问题:Intel Arc 显卡怎么在 Ollama 里启用 GPU 加速?请先 search_notes 检索运维笔记,再严格基于检索到的内容回答。"}})
}
func runAgent(messages []Message) {
const maxRounds = 8 // fuse, same as Parts 3/4: stop the model from looping forever
for round := 1; round <= maxRounds; round++ {
resp, err := chat(messages)
if err != nil {
fmt.Println("❌ 请求失败:", err)
return
}
if len(resp.Choices) == 0 {
fmt.Println("❌ 无响应")
return
}
msg := resp.Choices[0].Message
finish := resp.Choices[0].FinishReason
messages = append(messages, msg)
// Same as Parts 2 and 3: go by tool_calls being non-empty (finish_reason is
// only a cross-check — some upstreams still report "stop" while calling tools)
if len(msg.ToolCalls) == 0 {
fmt.Println("🤖", msg.Content)
return
}
if finish != "tool_calls" {
fmt.Printf(" ⚠️ finish_reason=%q 但本轮有 tool_calls,按 tool_calls 继续执行\n", finish)
}
fmt.Printf("🔧 第 %d 轮模型请求 %d 个工具\n", round, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
t, ok := tools[tc.Function.Name]
result := ""
if !ok {
result = fmt.Sprintf("未知工具 %q(模型幻觉了工具名)", tc.Function.Name)
} else {
result, err = t.run(json.RawMessage(tc.Function.Arguments))
if err != nil {
result = fmt.Sprintf("工具执行出错:%v。请修正参数后重试,或放弃这一步。", err)
}
}
if result == "" {
result = "(工具无输出)" // as in Part 3: a role="tool" message must carry content (Part 2 §3)
}
fmt.Printf(" - %s(%s)\n => %s\n", tc.Function.Name, tc.Function.Arguments, result)
messages = append(messages, Message{Role: "tool", ToolCallID: tc.ID, Content: result})
}
}
}
// ===========================================
// HTTP calls (chat and embed)
// ===========================================
var httpClient = &http.Client{Timeout: 5 * time.Minute} // request timeout: a stuck upstream cannot hang us forever
func chat(messages []Message) (ChatResponse, error) {
reqBody := ChatRequest{
Model: "llama3.1:8b",
Messages: messages,
Tools: toolDefs(),
Stream: false,
Temperature: 0, // greedy decoding keeps demo output stable; a chat scenario can go back to 0.7
}
jsonData, _ := json.Marshal(reqBody)
resp, err := httpClient.Post("http://localhost:11434/v1/chat/completions", "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return ChatResponse{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return ChatResponse{}, fmt.Errorf("upstream %d: %s", resp.StatusCode, string(b))
}
body, _ := io.ReadAll(resp.Body)
var result ChatResponse
if err := json.Unmarshal(body, &result); err != nil {
return ChatResponse{}, fmt.Errorf("解析失败: %s", string(body))
}
return result, nil
}
func embedTexts(texts []string) ([][]float64, error) {
payload, _ := json.Marshal(map[string]any{"model": "nomic-embed-text", "input": texts})
resp, err := httpClient.Post("http://localhost:11434/api/embed", "application/json", bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embed %d: %s", resp.StatusCode, string(b))
}
var out struct {
Embeddings [][]float64 `json:"embeddings"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Embeddings, nil
}
5. Run results (measured on this machine)
⚠️ Clear the old memory before running:
rm -f memory.json— otherwise it starts with the facts written by the previous run and session A looks like it did nothing.
rm -f memory.json # clean reproduction: do not carry history over
go run main.go
Real output (Ollama 0.33.3 + llama3.1:8b + nomic-embed-text, measured 2026-09-19; temperature=0, and this part’s prompt carries no dynamic content such as a timestamp, so it reproduces word for word):
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.
📚 检测到 nomic-embed-text,启用向量检索
===== 会话 A:让 Agent 记住一些事实 =====
🔧 第 1 轮模型请求 2 个工具
- remember({"key":"用户:名字","value":"小明"})
=> 已记住 用户:名字 = 小明
- remember({"key":"用户:语言","value":"Go"})
=> 已记住 用户:语言 = Go
🤖 这两次调用成功记住了用户的名字和语言。
===== 会话 B:换一个全新会话(历史不共享),问它记得我吗 =====
🔧 第 1 轮模型请求 2 个工具
- list_memory({})
=> 用户:名字 = 小明
用户:语言 = Go
- recall({"key":"用户:名字"})
=> 小明
🤖 你记得关于我的名字是“小明”,语言是Go。
===== 会话 C:查笔记回答问题(轻量 RAG)=====
🔧 第 1 轮模型请求 1 个工具
- search_notes({"query":"Intel Arc Ollama GPU 加速"})
=> (以下是检索到的笔记片段,请严格只依据这些内容回答,不要编造笔记里没有的细节)
[片段1 相似度0.78] Ollama 默认只识别 NVIDIA(CUDA) 与 AMD(ROCm) 显卡;Intel Arc 需要在 systemd override 里设置 OLLAMA_VULKAN=true 并重启服务,才会走 Vulkan 后端。
[片段2 相似度0.69] llama3.1:8b 在 Intel Arc A770 上实测 33/33 层全量 offload,模型占显存约 4.4GB,生成速度约 41 tokens/s。
[片段3 相似度0.65] 验证 Vulkan 是否生效:journalctl -u ollama --no-pager | grep 'inference compute',看到 library=Vulkan 与 Arc A770 的描述即为成功。
🤖 基于检索到的内容,答案如下:
要在 Ollama 里启用 GPU 加速的 Intel Arc 显卡,请按照以下步骤操作:
1. 在 systemd override 里设置 OLLAMA_VULKAN=true。
2. 重启 Ollama 服务。
这样,Ollama 就会使用 Vulkan 后端,来利用 Intel Arc 显卡的 GPU 加速功能。
另外,为了验证 Vulkan 是否生效,可以使用以下命令:
journalctl -u ollama --no-pager | grep 'inference compute'
如果看到 library=Vulkan 与 Arc A770 的描述,则说明 Vulkan 已经生效,GPU 加速功能已经启用。
Each of the three sessions demonstrates one thing:
- A (writing): two
remembercalls write facts intomemory.json; - B (reading across sessions): a brand-new session with no shared history enumerates the keys with
list_memory, fetches a value withrecall, and answers correctly — that is the difference between “memory” and “context”. To verify cross-process rather than merely cross-session, run two commands (the second is a separate process, so its in-processmessagesis guaranteed empty):
go run main.go # session A writes memory.json, then the process exits
go run main.go recall # a new process runs session B only: it still answers "小明 / Go"
The measured output of that second command (same memory.json, new process, no conversation history at all):
📚 检测到 nomic-embed-text,启用向量检索
===== 会话 B(新进程):换一个全新会话(历史不共享),问它记得我吗 =====
🔧 第 1 轮模型请求 2 个工具
- list_memory({})
=> 用户:名字 = 小明
用户:语言 = Go
- recall({"key":"用户:名字"})
=> 小明
🤖 你记得关于我的名字是“小明”,语言是Go。
This is “memory = external state + tools” in action: the model itself remembers nothing — it simply calls the tools again.
- C (RAG): retrieval hits the note about “setting
OLLAMA_VULKAN=true” (similarity 0.78), and the answer sticks strictly to the retrieved fragments, including the verification command.
6. Pitfalls and comparisons (verified by measurement)
| Symptom | Cause | Handling |
|---|---|---|
| Asking a small model to “extract facts by itself” writes wrong values | 8B models extract Chinese unstably (measured: it wrote 小明 as 小明积) |
Use an explicit key/value prompt for demos; in production let a stronger model extract, or validate before writing |
In a new session recall cannot guess the key |
Key-value memory requires “knowing the key names”, and those keys are invisible to the model | Provide the list_memory enumeration tool — enumeration is the precondition for “remembering” (stable in our measurements) |
| Retrieval is not accurate / pulls in unrelated fragments | For short, topically similar text the cosine scores sit in a narrow 0.5–0.8 band, so a fixed threshold filters almost nothing; the prefix effect is overstated too | Calibrate the threshold against your own corpus; let topK do the narrowing, back it with the “answer only from these fragments” instruction, and add the model’s prefixes as a convention |
| Retrieval is right but the model adds its own material | The model is not guaranteed to “copy the fragments” | State explicitly at the start of the tool result that answers must rely strictly on that content; production can add citation checking |
| Output differs every time and is hard to reproduce | Default sampling is random | Use temperature=0 for demos/tests, and raise it again for chat |
| The retrieval and chat models keep swapping each other out | Ollama keeps only a limited number of models resident per GPU by default | If VRAM allows, set OLLAMA_MAX_LOADED_MODELS=2 (see Part 1, Section 5.3) |
7. Deliberately simplified vs. production practice
| Deliberately simplified | What production does |
|---|---|
| Memory stored in a local JSON file | A database plus per-user isolation and expiry policies |
| Six fixed notes vectorized wholesale | Real chunking (cut documents into small pieces, overlapping neighbours so no meaning is sliced in half) plus a vector store (pgvector/sqlite-vec, …) |
| Pure vector retrieval | Hybrid retrieval (keywords + vectors) with reranking |
| A hard-coded 0.35 threshold | Tune and evaluate per embedding model and corpus |
| Fact memory is a “dead key-value store” | Summary-style long-term memory (compress old conversations into a summary and store that) |
| Memory is global (single user) | Isolate by user/session (用户:<id>:名字-style) plus a database and expiry policies |
Wiring memory into Part 4’s service (three steps, no changes to this part’s code)
Part 4’s conclusion says “the service still has no memory”, and this part goes back to a command-line demo — merging the two takes three steps:
- Swap the tool table: replace Part 4’s
tools(time and arithmetic) with this part’sremember/recall/list_memory/search_notes(copy thetoolsvariable; the loop code does not change). - Give a session somewhere to live:
handleChatalready hashistory []Message. The stateless option is to let the client send the history every time; to keep sessions server-side, add amap[string][]Messagekeyed bysession_id— and guard it with async.Mutex, because Go’s HTTP handlers run concurrently (something the command-line version never has to think about). - Give memory a dimension:
mem(memory.json) andretrieveare package-level shared state in a server, so several users would see each other’s facts. Before shipping, key entries by user (用户:<id>:名字-style) or give each user their own file or database layer.
On concurrency: Ollama does not serve unlimited requests in parallel either (
OLLAMA_NUM_PARALLEL,OLLAMA_MAX_QUEUE; see Part 1 §5.3). Once ten requests arrive at your service at once, the extras queue or get rejected — another gap between “demo” and “shipped”.
FAQ
| Question | Fix |
|---|---|
| Can it run without an embedding model? | Yes: the code degrades to keyword retrieval automatically (it probes /api/tags at startup), just with worse results |
| I pulled an embedding model but it still uses keywords | The probe runs once, at process start; restart the process after pulling to switch to vector retrieval |
memory.json keeps growing |
Design the keys properly and clean up periodically; use a database in real scenarios |
| Computing cosine over everything is slow with many notes | Index with a vector store; coarse-filter by tag or directory before ranking precisely |
Conclusion
- Memory = external state + tools: an agent cannot remember on its own, but it can “call a tool to read and write external storage”;
- Key-value memory needs enumeration, document memory needs retrieval: the two tools rest on two different ways of organizing information;
- RAG succeeds or fails on retrieval details: prefixes, thresholds and fragment splitting — make any one of them rough and the answer drifts;
- The series finale: from environment and operations → minimal code → the loop → service-ization → memory, a local agent that never touches Python now has the whole skeleton of “it runs, it calls tools, it serves, it remembers, and it looks things up”.

