Files
sundynix-agentix/sundynix-dispatcher/internal/eino/model.go
T
Blizzard 075d41f5b3 feat(llm): 本地模型接入 vLLM / Ollama + reasoning 思考过程入轨迹
vLLM 与 Ollama 都暴露 OpenAI 兼容 API(底层 go-openai 请求 {base}/chat/completions),
故统一走 openai 客户端,按 provider 归一化连接参数:
- Ollama/vLLM 的 OpenAI 端点固定在 /v1,BaseURL 漏写自动补全(否则打到 /chat/completions 404)
- 本地后端默认不校验 api_key → 缺省补占位(ollama→"ollama",vllm→"EMPTY";openai 客户端要求非空)
- 显式 key 一律尊重(vLLM --api-key 启动);在线 provider 原样不动(DeepSeek 两种都收)

reasoning_content 适配:ChatStream 增 onReasoning 回调,捕获 reasoning 模型
(本地 Qwen3 思考 / DeepSeek-R1 / QwQ、在线 deepseek-v4-pro)的思考分片。思考阶段分片
Content 为空本就不污染答案;runAgent 把思考累计后 surface 到 exec 轨迹「推理过程」事件。

控制台 ModelManager 加 ollama 选项;llm 包补 normalizeBaseURL / apiKeyOrPlaceholder 单测。
四模块全绿。live:经 admin 配 ollama qwen2.5:0.5b → dispatcher 热切(日志 base 自动 .../v1)
→ POST /v1/chat/completions 200 端到端出答案;切回 deepseek-v4-pro → exec 轨迹现「推理过程:思考26字…」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:10:18 +08:00

117 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"strings"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
)
// poolModel 把 LLM Pool 适配成 Eino 的 model.BaseChatModel
// 让 LLM Pool 能作为图中的 ChatModel 节点参与编排与流式。
// 真实接入 vLLM/Ollama 后,这里替换为后端的 Generate/Stream 即可。
type poolModel struct{ pool *llm.Pool }
func newPoolModel(p *llm.Pool) *poolModel { return &poolModel{pool: p} }
var _ model.BaseChatModel = (*poolModel)(nil)
// Generate 阻塞式生成(图被 Invoke 时用)。
func (pm *poolModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
var sb strings.Builder
var err error
if pm.pool.Ready() {
err = pm.pool.ChatStream(ctx, toChatMessages(input), func(tok string) { sb.WriteString(tok) }, nil)
} else {
err = pm.pool.StreamText(ctx, replyFor(input), func(tok []byte) { sb.Write(tok) })
}
if err != nil {
return nil, err
}
return schema.AssistantMessage(sb.String(), nil), nil
}
// Stream 流式生成(图被 Stream 时用):把回复按 token 推进 pipe。
// 已配置在线模型 → 真实 OpenAI 兼容流式;否则 → 注入记忆的降级桩。
func (pm *poolModel) Stream(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](32)
ready := pm.pool.Ready()
go func() {
defer sw.Close()
send := func(s string) { sw.Send(schema.AssistantMessage(s, nil), nil) }
var err error
if ready {
err = pm.pool.ChatStream(ctx, toChatMessages(input), send, nil)
} else {
err = pm.pool.StreamText(ctx, replyFor(input), func(tok []byte) { send(string(tok)) })
}
if err != nil {
sw.Send(nil, err)
}
}()
return sr, nil
}
// toChatMessages 把 Eino 消息转为 LLM Pool 的 OpenAI 兼容消息。
func toChatMessages(msgs []*schema.Message) []llm.ChatMessage {
out := make([]llm.ChatMessage, 0, len(msgs))
for _, m := range msgs {
role := "user"
switch m.Role {
case schema.System:
role = "system"
case schema.Assistant:
role = "assistant"
}
out = append(out, llm.ChatMessage{Role: role, Content: m.Content})
}
return out
}
// replyFor 是占位"模型":从消息中取出注入的画像与用户输入,
// 生成一段能体现"记忆已注入"的确定性回复(证明 recall→prompt 链路真的把画像喂进来了)。
// 真实模型不需要本函数。
func replyFor(msgs []*schema.Message) string {
var profile, user string
priorTurns := 0
for i, m := range msgs {
switch m.Role {
case schema.System:
profile = m.Content
case schema.Assistant:
priorTurns++ // 历史里的助手消息 = 过往轮次
case schema.User:
if i == len(msgs)-1 {
user = m.Content // 最后一条 user 才是本轮输入
}
}
}
return "【已注入用户画像】" + condense(profile, 80) +
"(本会话已有 " + itoa(priorTurns) + " 轮历史)" +
" | 据此为你个性化作答:已编排执行该 Agent 图(输入「" + condense(user, 30) + "」)。"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func condense(s string, max int) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
r := []rune(s)
if len(r) > max {
return string(r[:max]) + "…"
}
return s
}