Feat: 调用本地 Ollama

This commit is contained in:
2026-10-12 11:28:37 +08:00
parent c1a3461c49
commit 5d7228ebed
6 changed files with 98 additions and 43 deletions
+9 -8
View File
@@ -36,12 +36,13 @@ load_dotenv()
def llm_config() -> dict | None:
key = os.getenv("OPENAI_API_KEY", "").strip()
if not key:
openai_base = os.getenv("OPENAI_BASE_URL", "").strip()
if key and openai_base:
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip() or "gpt-4o-mini"
return {"provider": "openai", "key": key, "base": openai_base.rstrip("/"), "model": model}
base = os.getenv("OLLAMA_BASE_URL", "http://10.7.9.3:11434").strip()
if not base:
return None
base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").strip()
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini").strip()
return {
"key": key,
"base": (base or "https://api.openai.com/v1").rstrip("/"),
"model": model or "gpt-4o-mini",
}
model = os.getenv("OLLAMA_MODEL", "qwen3-vl:4b").strip() or "qwen3-vl:4b"
return {"provider": "ollama", "key": "ollama", "base": base.rstrip("/"), "model": model}
+75 -27
View File
@@ -1,4 +1,5 @@
import logging
import re
import httpx
@@ -6,10 +7,15 @@ from app.config import LOW_SCORE, llm_config
logger = logging.getLogger("learn_rag")
SYSTEM_PROMPT = """你是「知迹」的知识库问答助手。只能根据给出的知识切片回答,不要补充切片之外的信息,也不要编造。
如果切片不足以回答,就说明知识库里没有这项信息。
用自然的中文直接回答问题,不要使用 Markdown,不要写标题。
引用事实时在相关句末标注编号,格式必须是〔来源1〕这种全角括号。编号只能使用给定切片的编号。"""
# 发给服务器的第一条消息,role = system。
# 这里只有客服身份和回答规则,不含检索结果。换模型时这段一般不用改。
SYSTEM_PROMPT = """你是「知迹」的客服。用友好、耐心、自然的中文和顾客说话,像在柜台前当面回答。
规则:
1. 只能根据本次给出的知识切片回答,不要补充切片之外的信息,不要编造。
2. 先用「您好」一类的问候开场,再回答问题,并补上顾客需要知道的条件。语气像柜台客服,礼貌、清楚,不要只报两个数字。
3. 如果切片不够回答,坦诚说明现在查不到,并建议对方到服务台再确认。
4. 不要使用 Markdown,不要写标题,不要复述全部切片。
5. 引用事实时在相关句末标注〔来源1〕这种编号。编号只能使用给定切片的编号。"""
def build_context(chunks: list[dict]) -> str:
@@ -46,8 +52,23 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b
caution = ""
if low_confidence:
caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请明确说知识库里没有这项信息。\n\n"
user_prompt = f"{caution}知识切片如下:\n\n{build_context(chunks)}\n\n问题:{question}"
caution = "注意:最高相关度偏低。如果切片不能直接支持答案,请坦诚告诉顾客查不到。\n\n"
# 发给服务器的第二条消息,role = user。检索到的切片从这里进提示词。
# 拼出来的样子:
# 下面是检索到的知识切片,请据此回答顾客。
#
# 〔来源1〕《文档标题》相关度 75%
# 切片原文……
#
# 〔来源2〕《文档标题》相关度 71%
# 切片原文……
#
# 顾客的问题:柏卡会员年费多少,能借几本书?
user_prompt = (
f"{caution}下面是检索到的知识切片,请据此回答顾客。\n\n"
f"{build_context(chunks)}\n\n"
f"顾客的问题:{question}"
)
try:
answer = call_llm(settings, user_prompt)
except Exception:
@@ -57,29 +78,56 @@ def generate(question: str, chunks: list[dict]) -> tuple[str, str, str | None, b
return answer, "llm", None, low_confidence
def strip_think(text: str) -> str:
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def call_llm(settings: dict, user_prompt: str) -> str:
response = httpx.post(
f"{settings['base']}/chat/completions",
headers={"Authorization": f"Bearer {settings['key']}"},
json={
"model": settings["model"],
"temperature": 0.2,
"max_tokens": 800,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
},
timeout=httpx.Timeout(60.0, connect=10.0),
)
response.raise_for_status()
message = response.json()["choices"][0]["message"]
content = message.get("content")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
# 一次请求只生成一次回答。messages 就是上面两条:
# system = 客服身份;user = 知识切片 + 顾客问题。
# 换模型只改 OLLAMA_MODEL(现在是 qwen3-vl:4b),这两条提示词保持不变。
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
timeout = httpx.Timeout(180.0, connect=10.0)
if settings["provider"] == "ollama":
# POST {OLLAMA_BASE_URL}/api/chat
# think=false:不要把推理过程写进回答。
response = httpx.post(
f"{settings['base']}/api/chat",
json={
"model": settings["model"],
"messages": messages,
"stream": False,
"think": False,
"options": {"temperature": 0.4, "num_predict": 800},
},
timeout=timeout,
)
text = (content or "").strip()
response.raise_for_status()
message = response.json().get("message") or {}
text = strip_think(message.get("content") or "")
else:
response = httpx.post(
f"{settings['base']}/chat/completions",
headers={"Authorization": f"Bearer {settings['key']}"},
json={
"model": settings["model"],
"temperature": 0.4,
"max_tokens": 800,
"messages": messages,
},
timeout=timeout,
)
response.raise_for_status()
message = response.json()["choices"][0]["message"]
content = message.get("content")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
)
text = strip_think(content or "")
if not text:
raise ValueError("模型返回了空回答")
return text
+1
View File
@@ -127,6 +127,7 @@ def status(request: Request) -> dict:
"chunk_overlap": CHUNK_OVERLAP,
"llm_ready": llm is not None,
"llm_model": llm["model"] if llm else None,
"llm_provider": llm["provider"] if llm else None,
"documents": documents,
"chunks": chunks,
}