feat(ai-proxy): add context cleanup command support (#3409)

This commit is contained in:
澄潭
2026-01-30 17:56:31 +08:00
committed by GitHub
parent 8eaa385a56
commit 6c3fd46c6f
5 changed files with 513 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ description: AI 代理插件配置参考
| `reasoningContentMode` | string | 非必填 | - | 如何处理大模型服务返回的推理内容。目前支持以下取值passthrough正常输出推理内容、ignore不输出推理内容、concat将推理内容拼接在常规输出内容之前。默认为 passthrough。仅支持通义千问服务。 |
| `capabilities` | map of string | 非必填 | - | 部分 provider 的部分 ai 能力原生兼容 openai/v1 格式,不需要重写,可以直接转发,通过此配置项指定来开启转发, key 表示的是采用的厂商协议能力values 表示的真实的厂商该能力的 api path, 厂商协议能力当前支持: openai/v1/chatcompletions, openai/v1/embeddings, openai/v1/imagegeneration, openai/v1/audiospeech, cohere/v1/rerank |
| `subPath` | string | 非必填 | - | 如果配置了subPath将会先移除请求path中该前缀再进行后续处理 |
| `contextCleanupCommands` | array of string | 非必填 | - | 上下文清理命令列表。当请求的 messages 中存在完全匹配任意一个命令的 user 消息时,将该消息及之前所有非 system 消息清理掉,只保留 system 消息和该命令之后的消息。可用于主动清理对话上下文。 |
`context`的配置字段说明如下:
@@ -2389,11 +2390,92 @@ providers:
}
```
### 使用上下文清理命令
配置上下文清理命令后,用户可以通过发送特定消息来主动清理对话历史,实现"重新开始对话"的效果。
**配置信息**
```yaml
provider:
type: qwen
apiTokens:
- "YOUR_QWEN_API_TOKEN"
modelMapping:
"*": "qwen-turbo"
contextCleanupCommands:
- "清理上下文"
- "/clear"
- "重新开始"
- "新对话"
```
**请求示例**
当用户发送包含清理命令的请求时:
```json
{
"model": "gpt-3",
"messages": [
{
"role": "system",
"content": "你是一个助手"
},
{
"role": "user",
"content": "你好"
},
{
"role": "assistant",
"content": "你好!有什么可以帮助你的?"
},
{
"role": "user",
"content": "今天天气怎么样"
},
{
"role": "assistant",
"content": "抱歉,我无法获取实时天气信息。"
},
{
"role": "user",
"content": "清理上下文"
},
{
"role": "user",
"content": "现在开始新话题,介绍一下你自己"
}
]
}
```
**实际发送给 AI 服务的请求**
插件会自动清理"清理上下文"命令及之前的所有非 system 消息:
```json
{
"model": "qwen-turbo",
"messages": [
{
"role": "system",
"content": "你是一个助手"
},
{
"role": "user",
"content": "现在开始新话题,介绍一下你自己"
}
]
}
```
**说明**
- 清理命令必须完全匹配配置的字符串,部分匹配不会触发清理
- 当存在多个清理命令时,只处理最后一个匹配的命令
- 清理会保留所有 system 消息,删除命令及之前的 user、assistant、tool 消息
- 清理命令之后的所有消息都会保留
## 完整配置示例

View File

@@ -52,6 +52,7 @@ Plugin execution priority: `100`
| `context` | object | Optional | - | Configuration for AI conversation context information |
| `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests |
| `subPath` | string | Optional | - | If subPath is configured, the prefix will be removed from the request path before further processing. |
| `contextCleanupCommands` | array of string | Optional | - | List of context cleanup commands. When a user message in the request exactly matches any of the configured commands, that message and all non-system messages before it will be removed, keeping only system messages and messages after the command. This enables users to actively clear conversation history. |
**Details for the `context` configuration fields:**
@@ -2147,6 +2148,93 @@ providers:
}
```
### Using Context Cleanup Commands
After configuring context cleanup commands, users can actively clear conversation history by sending specific messages, achieving a "start over" effect.
**Configuration**
```yaml
provider:
type: qwen
apiTokens:
- "YOUR_QWEN_API_TOKEN"
modelMapping:
"*": "qwen-turbo"
contextCleanupCommands:
- "clear context"
- "/clear"
- "start over"
- "new conversation"
```
**Request Example**
When a user sends a request containing a cleanup command:
```json
{
"model": "gpt-3",
"messages": [
{
"role": "system",
"content": "You are an assistant"
},
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "Hello! How can I help you?"
},
{
"role": "user",
"content": "What's the weather like today"
},
{
"role": "assistant",
"content": "Sorry, I cannot get real-time weather information."
},
{
"role": "user",
"content": "clear context"
},
{
"role": "user",
"content": "Let's start a new topic, introduce yourself"
}
]
}
```
**Actual Request Sent to AI Service**
The plugin automatically removes the cleanup command and all non-system messages before it:
```json
{
"model": "qwen-turbo",
"messages": [
{
"role": "system",
"content": "You are an assistant"
},
{
"role": "user",
"content": "Let's start a new topic, introduce yourself"
}
]
}
```
**Notes**
- The cleanup command must exactly match the configured string; partial matches will not trigger cleanup
- When multiple cleanup commands exist in messages, only the last matching command is processed
- Cleanup preserves all system messages and removes user, assistant, and tool messages before the command
- All messages after the cleanup command are preserved
## Full Configuration Example
### Kubernetes Example

View File

@@ -421,6 +421,9 @@ type ProviderConfig struct {
// @Title zh-CN generic Provider 对应的Host
// @Description zh-CN 仅适用于generic provider用于覆盖请求转发的目标Host
genericHost string `required:"false" yaml:"genericHost" json:"genericHost"`
// @Title zh-CN 上下文清理命令
// @Description zh-CN 配置清理命令文本列表,当请求的 messages 中存在完全匹配任意一个命令的 user 消息时,将该消息及之前所有非 system 消息清理掉,实现主动清理上下文的效果
contextCleanupCommands []string `required:"false" yaml:"contextCleanupCommands" json:"contextCleanupCommands"`
// @Title zh-CN 首包超时
// @Description zh-CN 流式请求中收到上游服务第一个响应包的超时时间,单位为毫秒。默认值为 0表示不开启首包超时
firstByteTimeout uint32 `required:"false" yaml:"firstByteTimeout" json:"firstByteTimeout"`
@@ -461,6 +464,10 @@ func (c *ProviderConfig) GetVllmServerHost() string {
return c.vllmServerHost
}
func (c *ProviderConfig) GetContextCleanupCommands() []string {
return c.contextCleanupCommands
}
func (c *ProviderConfig) IsOpenAIProtocol() bool {
return c.protocol == protocolOpenAI
}
@@ -639,6 +646,12 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
c.vllmServerHost = json.Get("vllmServerHost").String()
c.vllmCustomUrl = json.Get("vllmCustomUrl").String()
c.doubaoDomain = json.Get("doubaoDomain").String()
c.contextCleanupCommands = make([]string, 0)
for _, cmd := range json.Get("contextCleanupCommands").Array() {
if cmd.String() != "" {
c.contextCleanupCommands = append(c.contextCleanupCommands, cmd.String())
}
}
}
func (c *ProviderConfig) Validate() error {
@@ -949,6 +962,16 @@ func (c *ProviderConfig) handleRequestBody(
log.Debugf("[Auto Protocol] converted Claude request body to OpenAI format")
}
// handle context cleanup command for chat completion requests
if apiName == ApiNameChatCompletion && len(c.contextCleanupCommands) > 0 {
body, err = cleanupContextMessages(body, c.contextCleanupCommands)
if err != nil {
log.Warnf("[contextCleanup] failed to cleanup context messages: %v", err)
// Continue processing even if cleanup fails
err = nil
}
}
// use openai protocol (either original openai or converted from claude)
if handler, ok := provider.(TransformRequestBodyHandler); ok {
body, err = handler.TransformRequestBody(ctx, apiName, body)

View File

@@ -73,6 +73,73 @@ func insertContextMessage(request *chatCompletionRequest, content string) {
}
}
// cleanupContextMessages 根据配置的清理命令清理上下文消息
// 查找最后一个完全匹配任意 cleanupCommands 的 user 消息,将该消息及之前所有非 system 消息清理掉,只保留 system 消息
func cleanupContextMessages(body []byte, cleanupCommands []string) ([]byte, error) {
if len(cleanupCommands) == 0 {
return body, nil
}
request := &chatCompletionRequest{}
if err := json.Unmarshal(body, request); err != nil {
return body, fmt.Errorf("unable to unmarshal request for context cleanup: %v", err)
}
if len(request.Messages) == 0 {
return body, nil
}
// 从后往前查找最后一个匹配任意清理命令的 user 消息
cleanupIndex := -1
for i := len(request.Messages) - 1; i >= 0; i-- {
msg := request.Messages[i]
if msg.Role == roleUser {
content := msg.StringContent()
for _, cmd := range cleanupCommands {
if content == cmd {
cleanupIndex = i
break
}
}
if cleanupIndex != -1 {
break
}
}
}
// 没有找到匹配的清理命令
if cleanupIndex == -1 {
return body, nil
}
log.Debugf("[contextCleanup] found cleanup command at index %d, cleaning up messages", cleanupIndex)
// 构建新的消息列表:
// 1. 保留 cleanupIndex 之前的 system 消息(只保留 system其他都清理
// 2. 删除 cleanupIndex 位置的清理命令消息
// 3. 保留 cleanupIndex 之后的所有消息
var newMessages []chatMessage
// 处理 cleanupIndex 之前的消息,只保留 system
for i := 0; i < cleanupIndex; i++ {
msg := request.Messages[i]
if msg.Role == roleSystem {
newMessages = append(newMessages, msg)
}
}
// 跳过 cleanupIndex 位置的消息(清理命令本身)
// 保留 cleanupIndex 之后的所有消息
for i := cleanupIndex + 1; i < len(request.Messages); i++ {
newMessages = append(newMessages, request.Messages[i])
}
request.Messages = newMessages
log.Debugf("[contextCleanup] messages after cleanup: %d", len(newMessages))
return json.Marshal(request)
}
func ReplaceResponseBody(body []byte) error {
log.Debugf("response body: %s", string(body))
err := proxywasm.ReplaceHttpResponseBody(body)

View File

@@ -0,0 +1,253 @@
package provider
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCleanupContextMessages(t *testing.T) {
t.Run("empty_cleanup_commands", func(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`)
result, err := cleanupContextMessages(body, []string{})
assert.NoError(t, err)
assert.Equal(t, body, result)
})
t.Run("no_matching_command", func(t *testing.T) {
body := []byte(`{"messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"hello"}]}`)
result, err := cleanupContextMessages(body, []string{"清理上下文", "/clear"})
assert.NoError(t, err)
assert.Equal(t, body, result)
})
t.Run("cleanup_with_single_command", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "新问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
assert.Len(t, output.Messages, 2)
assert.Equal(t, "system", output.Messages[0].Role)
assert.Equal(t, "你是一个助手", output.Messages[0].Content)
assert.Equal(t, "user", output.Messages[1].Role)
assert.Equal(t, "新问题", output.Messages[1].Content)
})
t.Run("cleanup_with_multiple_commands_match_first", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!"},
{Role: "user", Content: "/clear"},
{Role: "user", Content: "新问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文", "/clear", "重新开始"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
assert.Len(t, output.Messages, 2)
assert.Equal(t, "system", output.Messages[0].Role)
assert.Equal(t, "user", output.Messages[1].Role)
assert.Equal(t, "新问题", output.Messages[1].Content)
})
t.Run("cleanup_removes_tool_messages", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "查天气"},
{Role: "assistant", Content: ""},
{Role: "tool", Content: "北京 25°C"},
{Role: "assistant", Content: "北京今天25度"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "新问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
assert.Len(t, output.Messages, 2)
assert.Equal(t, "system", output.Messages[0].Role)
assert.Equal(t, "user", output.Messages[1].Role)
})
t.Run("cleanup_keeps_multiple_system_messages", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "系统提示1"},
{Role: "system", Content: "系统提示2"},
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "新问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
assert.Len(t, output.Messages, 3)
assert.Equal(t, "system", output.Messages[0].Role)
assert.Equal(t, "系统提示1", output.Messages[0].Content)
assert.Equal(t, "system", output.Messages[1].Role)
assert.Equal(t, "系统提示2", output.Messages[1].Content)
assert.Equal(t, "user", output.Messages[2].Role)
})
t.Run("cleanup_finds_last_matching_command", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "中间问题"},
{Role: "assistant", Content: "中间回答"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "最后问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
// 应该匹配最后一个清理命令,保留 system 和 "最后问题"
assert.Len(t, output.Messages, 2)
assert.Equal(t, "system", output.Messages[0].Role)
assert.Equal(t, "user", output.Messages[1].Role)
assert.Equal(t, "最后问题", output.Messages[1].Content)
})
t.Run("cleanup_at_end_of_messages", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!"},
{Role: "user", Content: "清理上下文"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
// 清理命令在最后,只保留 system
assert.Len(t, output.Messages, 1)
assert.Equal(t, "system", output.Messages[0].Role)
})
t.Run("cleanup_without_system_message", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!"},
{Role: "user", Content: "清理上下文"},
{Role: "user", Content: "新问题"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
// 没有 system 消息,只保留清理命令之后的消息
assert.Len(t, output.Messages, 1)
assert.Equal(t, "user", output.Messages[0].Role)
assert.Equal(t, "新问题", output.Messages[0].Content)
})
t.Run("cleanup_with_empty_messages", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
var output chatCompletionRequest
err = json.Unmarshal(result, &output)
require.NoError(t, err)
assert.Len(t, output.Messages, 0)
})
t.Run("cleanup_command_partial_match_not_triggered", func(t *testing.T) {
input := chatCompletionRequest{
Messages: []chatMessage{
{Role: "system", Content: "你是一个助手"},
{Role: "user", Content: "请清理上下文吧"},
{Role: "assistant", Content: "好的"},
},
}
body, err := json.Marshal(input)
require.NoError(t, err)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.NoError(t, err)
// 部分匹配不应触发清理
assert.Equal(t, body, result)
})
t.Run("invalid_json_body", func(t *testing.T) {
body := []byte(`invalid json`)
result, err := cleanupContextMessages(body, []string{"清理上下文"})
assert.Error(t, err)
assert.Equal(t, body, result)
})
}