feat(react): demonstrate short-term memory

Change-Id: Ib2b796a7f66ee33b9115c9331489c8d86e42b8c5
drew/english
shentong.martin 5 months ago committed by shentongmartin
parent d35371e0af
commit 77bd95ba71

@ -0,0 +1,97 @@
# Short-Term Memory for ReAct Agent
This example demonstrates a minimal short-term memory for a `flow/react` agent:
1. Run the agent with a new input message list, get the assistant output.
2. Serialize and persist the original input messages plus the assistant output.
3. On the next run, restore the stored messages, append the new input, and continue the conversation.
4. Do not persist the system message; inject it at runtime via `MessageModifier`.
5. Storage options include an in-memory map and Redis (with optional in-memory `miniredis`).
## Where to Look
- `main.go` — minimal demo: two turns that share memory and a system prompt injected at runtime.
- `memory/store.go``MemoryStore` interface and Gob encode/decode helpers.
- `memory/inmem.go` — in-memory store.
- `memory/redis.go` — Redis-backed store and `NewMiniRedisClient()` for an embedded Redis server.
## System Prompt Handling
- Do not persist system messages.
- Use the agent hook `react.AgentConfig.MessageModifier` to prepend the system prompt at execution time:
```go
agent, _ := react.NewAgent(ctx, &react.AgentConfig{
Model: model,
MessageModifier: func(_ context.Context, input []*schema.Message) []*schema.Message {
return append([]*schema.Message{schema.SystemMessage(sys)}, input...)
},
})
```
## Serialization
- Messages are serialized using `encoding/gob`.
- Eino registers the necessary types, so no manual `gob.Register` is required here.
## Quick Start (OpenAI)
Environment variables:
- `OPENAI_API_KEY`
- `OPENAI_MODEL` (e.g., `gpt-4o-mini`)
- `OPENAI_BASE_URL` (optional for proxy endpoints)
- `OPENAI_BY_AZURE`
Build and run:
```bash
cd flow/agent/react/memory_example
go build -o memory_example main.go
./memory_example
```
Expected output:
- First run: prints assistant response; memory stores the turn.
- Second run: restores prior messages, appends the new input, and maintains context.
## Switch Storage Implementations
Use the in-memory store (default):
```go
store := memory.NewInMemoryStore()
```
Use Redis with in-memory `miniredis`:
```go
cli, closer, _ := memory.NewMiniRedisClient()
defer closer()
store := memory.NewRedisStore(cli)
```
Use Redis with a real server:
```go
cli := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store := memory.NewRedisStore(cli)
```
## Minimal Flow
```go
sessionID := "session:demo"
prev, _ := store.Read(ctx, sessionID)
effective := append(prev, schema.UserMessage(userInput))
resp, _ := agent.Generate(ctx, effective)
_ = store.Write(ctx, sessionID, append(effective, resp))
hits, _ := store.Query(ctx, sessionID, "CloudWeGo", 3)
```
## Notes
- The example uses `Generate`. You can use `Stream` similarly and persist on `io.EOF`.
- Keep the memory window small to cap serialization size; this can be enforced by your store implementation.

@ -0,0 +1,98 @@
/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package main
import (
"context"
"fmt"
"os"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/eino-examples/flow/agent/react/memory_example/memory"
)
func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
modelName := os.Getenv("OPENAI_MODEL")
baseURL := os.Getenv("OPENAI_BASE_URL")
isAzure := os.Getenv("OPENAI_BY_AZURE") == "true"
model, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{APIKey: apiKey, Model: modelName, BaseURL: baseURL, ByAzure: isAzure})
if err != nil {
panic(err)
}
// System prompt is injected at runtime and not persisted.
sys := "You are a concise assistant. Maintain context across turns."
agent, err := react.NewAgent(ctx, &react.AgentConfig{
Model: model,
MessageModifier: func(_ context.Context, input []*schema.Message) []*schema.Message {
return append([]*schema.Message{schema.SystemMessage(sys)}, input...)
},
})
if err != nil {
panic(err)
}
// Choose your store: InMemoryStore (default) or RedisStore (see README).
store := memory.NewInMemoryStore()
sessionID := "session:demo"
verifyGobRoundTrip()
run := func(turn string) {
// 1) restore prior messages, 2) append new input, 3) call agent, 4) persist with output
prev, _ := store.Read(ctx, sessionID)
eff := append(prev, schema.UserMessage(turn))
msg, err := agent.Generate(ctx, eff)
if err != nil {
panic(err)
}
fmt.Printf("history_before=%d after=%d\n", len(prev), len(eff)+1)
fmt.Println(msg.Content)
_ = store.Write(ctx, sessionID, append(eff, msg))
hits, _ := store.Query(ctx, sessionID, "AI", 3)
fmt.Printf("query_hits=%d\n", len(hits))
}
run("Hello, summarize AI briefly.")
run("Add two more details.")
}
func verifyGobRoundTrip() {
msgs := []*schema.Message{
schema.UserMessage("a"),
schema.AssistantMessage("b", nil),
}
// Round-trip serialize/deserialize to validate gob setup.
b, err := memory.EncodeMessages(msgs)
if err != nil {
panic(err)
}
out, err := memory.DecodeMessages(b)
if err != nil {
panic(err)
}
fmt.Printf("gob_round_trip=%d\n", len(out))
}

@ -0,0 +1,81 @@
/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package memory
import (
"context"
"strings"
"sync"
"github.com/cloudwego/eino/schema"
)
// InMemoryStore keeps serialized messages in a process-local map.
// Suitable for demos/tests; not shared across processes.
type InMemoryStore struct {
mu sync.RWMutex
data map[string][]byte
}
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{data: make(map[string][]byte)}
}
// Write encodes and stores messages for the given key.
func (s *InMemoryStore) Write(ctx context.Context, sessionID string, msgs []*schema.Message) error {
b, err := EncodeMessages(msgs)
if err != nil {
return err
}
s.mu.Lock()
s.data[sessionID] = b
s.mu.Unlock()
return nil
}
// Read returns decoded messages for the given session; returns nil if absent.
func (s *InMemoryStore) Read(ctx context.Context, sessionID string) ([]*schema.Message, error) {
s.mu.RLock()
b := s.data[sessionID]
s.mu.RUnlock()
return DecodeMessages(b)
}
// Query performs a simple substring search on message contents for the session.
func (s *InMemoryStore) Query(ctx context.Context, sessionID string, text string, limit int) ([]*schema.Message, error) {
msgs, err := s.Read(ctx, sessionID)
if err != nil {
return nil, err
}
if len(msgs) == 0 || text == "" {
return nil, nil
}
q := strings.ToLower(text)
out := make([]*schema.Message, 0, limit)
for _, m := range msgs {
if m == nil {
continue
}
if strings.Contains(strings.ToLower(m.Content), q) {
out = append(out, m)
if limit > 0 && len(out) >= limit {
break
}
}
}
return out, nil
}

@ -0,0 +1,91 @@
/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package memory
import (
"context"
"strings"
miniredis "github.com/alicebob/miniredis/v2"
"github.com/cloudwego/eino/schema"
"github.com/redis/go-redis/v9"
)
// RedisStore persists serialized messages in Redis under the provided session key.
type RedisStore struct {
cli *redis.Client
}
func NewRedisStore(cli *redis.Client) *RedisStore {
return &RedisStore{cli: cli}
}
// Write encodes and stores messages using Redis SET.
func (s *RedisStore) Write(ctx context.Context, sessionID string, msgs []*schema.Message) error {
b, err := EncodeMessages(msgs)
if err != nil {
return err
}
return s.cli.Set(ctx, sessionID, b, 0).Err()
}
// Read returns decoded messages from Redis GET; returns nil if not found.
func (s *RedisStore) Read(ctx context.Context, sessionID string) ([]*schema.Message, error) {
res, err := s.cli.Get(ctx, sessionID).Bytes()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
return DecodeMessages(res)
}
func (s *RedisStore) Query(ctx context.Context, sessionID string, text string, limit int) ([]*schema.Message, error) {
msgs, err := s.Read(ctx, sessionID)
if err != nil {
return nil, err
}
if len(msgs) == 0 || text == "" {
return nil, nil
}
out := make([]*schema.Message, 0, limit)
q := strings.ToLower(text)
for _, m := range msgs {
if m == nil {
continue
}
if strings.Contains(strings.ToLower(m.Content), q) {
out = append(out, m)
if limit > 0 && len(out) >= limit {
break
}
}
}
return out, nil
}
// NewMiniRedisClient starts an embedded Redis server for local demos/tests.
func NewMiniRedisClient() (*redis.Client, func(), error) {
srv, err := miniredis.Run()
if err != nil {
return nil, nil, err
}
cli := redis.NewClient(&redis.Options{Addr: srv.Addr()})
closer := func() { srv.Close() }
return cli, closer, nil
}

@ -0,0 +1,58 @@
/*
* Copyright 2025 CloudWeGo Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package memory
import (
"bytes"
"context"
"encoding/gob"
"github.com/cloudwego/eino/schema"
)
// MemoryStore persists and restores short-term conversation history.
// Implementations are responsible for storing a slice of messages under a session key.
type MemoryStore interface {
Write(ctx context.Context, sessionID string, msgs []*schema.Message) error
Read(ctx context.Context, sessionID string) ([]*schema.Message, error)
Query(ctx context.Context, sessionID string, text string, limit int) ([]*schema.Message, error)
}
// Gob registrations for eino message types are provided by the framework; no manual registration needed here.
// EncodeMessages serializes messages using Gob.
func EncodeMessages(msgs []*schema.Message) ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(msgs); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// DecodeMessages deserializes messages previously encoded by EncodeMessages.
func DecodeMessages(b []byte) ([]*schema.Message, error) {
if len(b) == 0 {
return nil, nil
}
dec := gob.NewDecoder(bytes.NewReader(b))
var msgs []*schema.Message
if err := dec.Decode(&msgs); err != nil {
return nil, err
}
return msgs, nil
}

@ -35,6 +35,7 @@ require (
require (
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
@ -43,6 +44,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@ -50,6 +52,7 @@ require (
github.com/cohesion-org/deepseek-go v1.3.2 // indirect
github.com/corpix/uarand v0.2.0 // indirect
github.com/coze-dev/cozeloop-go/spec v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dslipak/pdf v0.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@ -80,6 +83,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@ -96,6 +100,7 @@ require (
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/arch v0.19.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect

@ -56,6 +56,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@ -101,9 +103,12 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
@ -164,6 +169,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
@ -529,6 +536,8 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
@ -626,6 +635,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=

Loading…
Cancel
Save