This commit is contained in:
2026-08-10 14:07:48 +08:00
parent 5eac36799b
commit 6587b1d783
7 changed files with 83 additions and 13 deletions
+1
View File
@@ -43,6 +43,7 @@
- **新增并行点的固定三处**:`common/pool.go` 加池变量(grpool 封装)→ `biz/consts` 加默认大小 → `config.yml``pool` 段加 `key: 并发度`(缺失或非法时回退默认值)
- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池(池清单与默认值见 README 配置说明)
- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine
- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外)
- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程
## 文档职责(三文档体系)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -26,7 +26,10 @@ const (
MaxReactRounds = 10 // ReAct 轮次上限
ToolResultMaxChars = 1500 // search 工具返回的单条 chunk 截断字数(控制上下文体积)
ParsePollIntervalSeconds = 5 // 解析/标注任务轮询间隔
ParsePollIntervalSeconds = 15 // 解析/标注任务轮询间隔
DefaultLlmHttpTimeout = 600 // LLM HTTP 客户端超时(秒):本地模型生成慢,且多请求排队时响应可超分钟级
MaxExtractTokens = 4096 // 知识抽取单次生成的 token 预算(防失控生成烧掉上下文)
EmbedBatchSize = 16 // 单次向量化请求的文本批量
+17 -4
View File
@@ -33,8 +33,6 @@ import (
"github.com/gogf/gf/v2/frame/g"
)
var httpClient = &http.Client{Timeout: 2 * time.Minute}
// ---------- OpenAI 兼容 HTTP 组件 ----------
type openAIMessage struct {
@@ -76,10 +74,17 @@ type openAIStreamChunk struct {
type OpenAIChatModel struct {
cfg *entity.ModelConfig
tools []*schema.ToolInfo
extra map[string]any // 附加请求字段(如 gemma-4 关闭思维链)
}
func NewOpenAIChatModel(cfg *entity.ModelConfig) *OpenAIChatModel {
return &OpenAIChatModel{cfg: cfg}
return &OpenAIChatModel{cfg: cfg, extra: map[string]any{}}
}
// DisableThinking 关闭模型思维链:本地 gemma-4-E4B 偶发长思考会烧光上下文,导致返回空内容
func (m *OpenAIChatModel) DisableThinking() *OpenAIChatModel {
m.extra["thinking"] = false
return m
}
func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...emodel.Option) (*schema.Message, error) {
@@ -88,6 +93,12 @@ func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message,
"messages": buildOpenAIMessages(input),
"stream": false,
}
for k, v := range m.extra {
payload[k] = v
}
if commonOpts := emodel.GetCommonOptions(nil, opts...); commonOpts.MaxTokens != nil {
payload["max_tokens"] = *commonOpts.MaxTokens
}
m.withTools(payload)
body, err := postOpenAI(ctx, m.cfg, m.endpoint("/chat/completions"), payload)
if err != nil {
@@ -1174,7 +1185,9 @@ func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, p
if cfg.ApiKey != "" {
req.Header.Set("Authorization", "Bearer "+cfg.ApiKey)
}
resp, err := httpClient.Do(req)
// 本地模型生成慢且多请求排队时响应可超分钟级,超时取 config.yml chat.timeout(秒)
timeout := g.Cfg().MustGet(ctx, "chat.timeout", consts.DefaultLlmHttpTimeout).Int()
resp, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(req)
if err != nil {
g.Log().Errorf(ctx, "model call failed: model=%s url=%s err=%v", cfg.ModelName, url, err)
return nil, err
+61 -8
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
@@ -11,9 +12,9 @@ import (
"rag-local/kb/dao"
"rag-local/kb/model/entity"
emodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
var KgEntityService = &kgEntityService{}
@@ -48,6 +49,8 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume
if err != nil {
return 0, err
}
// 池内并发共享 model 实例,只读字段须在此(单 goroutine)预置好
model.DisableThinking()
type extractOut struct {
chunkId int64
@@ -69,7 +72,7 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume
ch <- extractOut{chunkId: c.Id, err: e}
}); err != nil {
wg.Done()
g.Log().Warningf(ctx, "kg extract chunk %d submit failed: %v", c.Id, err)
fmt.Printf("kg extract chunk %d submit failed: %v\n", c.Id, err)
}
}
go func() { wg.Wait(); close(ch) }()
@@ -78,12 +81,12 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume
for out := range ch {
if out.err != nil {
failed++
g.Log().Warningf(ctx, "kg extract chunk %d failed: %v", out.chunkId, out.err)
fmt.Printf("kg extract chunk %d failed: %v\n", out.chunkId, out.err)
continue
}
if err := s.saveExtract(ctx, datasetId, out.chunkId, out.entities, out.relations); err != nil {
failed++
g.Log().Warningf(ctx, "kg extract chunk %d failed: %v", out.chunkId, err)
fmt.Printf("kg extract chunk %d failed: %v\n", out.chunkId, err)
}
}
return failed, nil
@@ -106,13 +109,22 @@ func (s *kgEntityService) callExtract(ctx context.Context, model *OpenAIChatMode
{Role: schema.System, Content: "你是知识抽取助手。从文档片段中抽取实体(人名、组织、地名、产品等专有名词)及实体间的关系(动词或介词短语)。只输出 JSON,不要 markdown 代码块或任何解释,格式:{\"entities\":[{\"name\":\"实体名\",\"type\":\"类型\"}],\"relations\":[{\"head\":\"主体\",\"relation\":\"关系\",\"tail\":\"客体\"}]}"},
{Role: schema.User, Content: "文档片段:\n" + chunk.Content},
}
resp, err := model.Generate(ctx, msgs)
resp, err := model.Generate(ctx, msgs, emodel.WithMaxTokens(consts.MaxExtractTokens))
if err != nil {
return nil, nil, err
}
data, err := parseKgJSON(resp.Content)
if err != nil {
return nil, nil, err
// 模型偶发返回空内容/截断 JSON,重试一次通常正常
fmt.Printf("kg extract chunk %d parse failed (%v), retrying\n", chunk.Id, err)
resp, err = model.Generate(ctx, msgs, emodel.WithMaxTokens(consts.MaxExtractTokens))
if err != nil {
return nil, nil, err
}
data, err = parseKgJSON(resp.Content)
if err != nil {
return nil, nil, err
}
}
entities := make([]kgEntityItem, 0, len(data.Entities))
for _, e := range data.Entities {
@@ -154,7 +166,7 @@ func (s *kgEntityService) saveExtract(ctx context.Context, datasetId, chunkId in
return nil
}
// parseKgJSON 解析模型输出的 JSON,容忍 ```json 代码块包裹
// parseKgJSON 解析模型输出的 JSON,容忍 ```json 代码块包裹及首个 JSON 对象后的多余内容(模型偶尔续写 ",{...}"
func parseKgJSON(content string) (*kgExtractResult, error) {
content = strings.TrimSpace(content)
content = strings.TrimPrefix(content, "```json")
@@ -163,11 +175,52 @@ func parseKgJSON(content string) (*kgExtractResult, error) {
content = strings.TrimSpace(content)
var out kgExtractResult
if err := json.Unmarshal([]byte(content), &out); err != nil {
return nil, gerror.Wrap(err, "解析抽取 JSON 失败")
if fixed, ok := extractFirstJSON(content); ok {
if err2 := json.Unmarshal([]byte(fixed), &out); err2 == nil {
return &out, nil
}
}
return nil, gerror.Wrapf(err, "解析抽取 JSON 失败,模型输出:%q", truncateRunes(content, 200))
}
return &out, nil
}
// extractFirstJSON 提取字符串中第一个完整 JSON 对象(平衡花括号,跳过字符串内的花括号)
func extractFirstJSON(s string) (string, bool) {
start := strings.IndexByte(s, '{')
if start < 0 {
return "", false
}
depth := 0
inStr := false
escaped := false
for i := start; i < len(s); i++ {
ch := s[i]
if inStr {
if escaped {
escaped = false
} else if ch == '\\' {
escaped = true
} else if ch == '"' {
inStr = false
}
continue
}
switch ch {
case '"':
inStr = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return s[start : i+1], true
}
}
}
return "", false
}
func (s *kgEntityService) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgEntity, int, error) {
return dao.KgEntity.List(ctx, datasetId, page, pageSize)
}