lanlao / systemone_server.py
gxydResearch's picture
Upload lanlao: LoRA adapter (rlcd_score43k_v5.pt) + serving code + benchmarks
a46df65 verified
Raw History Blame Contribute Delete
25.1 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Phase 10.1(RESEARCH_ROADMAP):/v1/systemone 本地兼容服务(Jev 客户端可无缝切换)。
契约(对齐官方 typesafe_demo.py + jev_live_eval.py 实测行级输出):
POST /v1/systemone {"state": str, "model": str, "questions": {name: {"type": "noul"|"choice"|"score", "instructions": str, "criteria": ...}}}
- choice: criteria = {id: description}(无序)
- score: criteria = [description, ...](由低到高有序等级)
- noul: 无 criteria,instructions 即判据
响应 {"answers": {name: answer}, "model", "usage": {"input_tokens", "output_tokens"}, "latency_ms"}
- noul: {"noul": p_yes, "confidence": max(p_yes, 1-p_yes), "probabilities": {"yes": p, "no": 1-p}}
- choice: {"choice": id, "confidence": maxp, "probabilities": {id: p}}
- score: {"score": E[rank]/(k-1) ∈ [0,1], "confidence": maxp, "probabilities": {description: p}}
实现:frozen Qwen3.5-4B + 三种打分模式
--mode direct SemIf direct-logit(末位 logits 限 A-P slot → softmax,enable_thinking=False 硬前提)
--mode lora LoRA checkpoint(scale_lora_train/rlcd_train 产物)merge 进基座后走 direct-logit;最终模型 rlcd_score43k_v5.pt(Phase 11 选型,authored 0.958/perturb 0.972)
--mode head AttentionHead checkpoint(scale_head_train 产物)+ 冻结编码器(KV-Cache 广播是 10.2,本版逐问题前向)
confidence 一律为分布汇总值(无第二头,对齐 Jev 黑盒形态)。
CLI(远程 4090):CUDA_VISIBLE_DEVICES=1 python systemone_server.py --model /home/chenzhimin/modelscope/models/Qwen--Qwen3.5-4B/snapshots/master --port 8100 --mode lora --lora-checkpoint /data20T/semif-results/rlcd_score43k_v5.pt
自测:python systemone_server.py --selftest ...(跑官方 demo 5 场景,不调外部 API)
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).parent))
from semif_phase1.core import LETTERS, DIRECT_SYSTEM # noqa: E402
from toy_dual_head import load_torch # noqa: E402
import torch # noqa: E402
from torch import nn # noqa: E402
import torch.nn.functional as F # noqa: E402
LETTER_SLOTS_CACHE: dict[str, int] = {}
def softmax(values: list[float]) -> list[float]:
peak = max(values)
weights = [math.exp(v - peak) for v in values]
total = sum(weights)
return [w / total for w in weights]
class DirectScorer:
"""SemIf direct-logit:末位 logits 限 A-P slot → softmax。score_batch 为 10.2 优化路径(共享前缀 KV-Cache 广播)。"""
def __init__(self, model, tokenizer, device) -> None:
self.model = model
self.tokenizer = tokenizer
self.device = device
def build_prompt(self, state: str, instructions: str, option_descriptions: list[str]) -> str:
payload = {"evidence": state, "criterion": instructions, "options": [{"letter": LETTERS[i], "description": description} for i, description in enumerate(option_descriptions)]}
return self.tokenizer.apply_chat_template([{"role": "system", "content": DIRECT_SYSTEM}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}], tokenize=False, add_generation_prompt=True, enable_thinking=False)
def score(self, state: str, instructions: str, option_descriptions: list[str]) -> list[float]:
prompt = self.build_prompt(state, instructions, option_descriptions)
ids = self.tokenizer.encode(prompt, add_special_tokens=False)
with torch.inference_mode():
inp = torch.tensor([ids], dtype=torch.long, device=self.device)
logits = self.model(input_ids=inp).logits[0, -1].float().cpu()
return self._slot_softmax(logits, option_descriptions)
def _slot_softmax(self, logits: torch.Tensor, option_descriptions: list[str]) -> list[float]:
slots = []
for i in range(len(option_descriptions)):
letter = LETTERS[i]
if letter not in LETTER_SLOTS_CACHE:
token_ids = self.tokenizer.encode(letter, add_special_tokens=False)
if len(token_ids) != 1:
raise ValueError(f"slot {letter} not single token: {token_ids}")
LETTER_SLOTS_CACHE[letter] = token_ids[0]
slots.append(LETTER_SLOTS_CACHE[letter])
selected = [float(logits[slot]) for slot in slots]
return softmax(selected)
def score_batch(self, state: str, questions: list[tuple[str, list[str]]]) -> list[list[float]]:
"""10.2:同一 state 的多问题共享前缀——prefix 编码一次(线性态缓存),每问题恢复快照再前向后缀。失败自动回退逐条全量前向。"""
if len(questions) <= 1 or not self._cache_supported():
return [self.score(state, instructions, option_descriptions) for instructions, option_descriptions in questions]
prompts = [self.build_prompt(state, instructions, option_descriptions) for instructions, option_descriptions in questions]
encoded = [self.tokenizer.encode(prompt, add_special_tokens=False) for prompt in prompts]
common = 0
limit = min(len(ids) for ids in encoded)
while common < limit and all(ids[common] == encoded[0][common] for ids in encoded):
common += 1
if common < 32:
return [self.score(state, instructions, option_descriptions) for instructions, option_descriptions in questions]
try:
results = []
prefix_ids = encoded[0][:common]
with torch.inference_mode():
prefix_tensor = torch.tensor([prefix_ids], dtype=torch.long, device=self.device)
cache = self._make_cache()
self.model(input_ids=prefix_tensor, past_key_values=cache, use_cache=True)
snapshot = self._snapshot(cache)
for index, ids in enumerate(encoded):
self._restore(cache, snapshot)
suffix = torch.tensor([ids[common:]], dtype=torch.long, device=self.device)
logits = self.model(input_ids=suffix, past_key_values=cache, use_cache=True).logits[0, -1].float().cpu()
option_descriptions = questions[index][1]
results.append(self._slot_softmax(logits, option_descriptions))
return results
except Exception as error:
print(f"[score_batch] cache path failed ({type(error).__name__}: {error}), falling back to full forward", flush=True)
self._cache_ok = False
return [self.score(state, instructions, option_descriptions) for instructions, option_descriptions in questions]
def _cache_supported(self) -> bool:
return bool(getattr(self, "_cache_ok", True))
def _make_cache(self):
from transformers import DynamicCache
config = getattr(self.model, "config", None)
if config is not None:
try:
return DynamicCache(config=config)
except Exception:
pass
return DynamicCache()
def _snapshot(self, cache) -> list:
snapshot = []
names = ("conv_states", "recurrent_states", "key_states", "value_states")
for layer in cache.layers:
snapshot.append(tuple((name, self._clone_state(getattr(layer, name, None))) for name in names))
return snapshot
def _restore(self, cache, snapshot: list) -> None:
for layer, entries in zip(cache.layers, snapshot):
for name, state in entries:
if state is not None:
setattr(layer, name, self._clone_state(state))
def _clone_state(self, state):
if state is None:
return None
if isinstance(state, torch.Tensor):
return state.detach().clone()
if isinstance(state, dict):
return {key: self._clone_state(value) for key, value in state.items()}
if isinstance(state, (list, tuple)):
cloned = [self._clone_state(item) for item in state]
return tuple(cloned) if isinstance(state, tuple) else cloned
return state
class HeadScorer:
"""AttentionHead(scale_head_train checkpoint)+ 冻结编码器。context = chat prompt 全 token hidden,option = 描述 mean-pool。"""
def __init__(self, model, tokenizer, device, checkpoint: Path, rank: int = 64, context_tokens: int = 640, option_tokens: int = 64) -> None:
from scale_head_train import AttentionHead # noqa: F401 — 同步实现复用
self.model = model
self.tokenizer = tokenizer
self.device = device
self.context_tokens = context_tokens
self.option_tokens = option_tokens
payload = torch.load(checkpoint, map_location="cpu", weights_only=False)
config = payload.get("config", {})
self.head = AttentionHead(self.resolve_hidden(), int(config.get("head_rank", rank))).to(device)
self.head.load_state_dict(payload["state_dict"])
self.head.eval()
def resolve_hidden(self) -> int:
hidden = getattr(self.model.config, "hidden_size", None)
if hidden is None and hasattr(self.model.config, "get_text_config"):
hidden = self.model.config.get_text_config().hidden_size
if hidden is None:
raise ValueError(f"cannot resolve hidden_size from config {type(self.model.config)}")
return int(hidden)
def encode_context(self, prompt: str) -> tuple[torch.Tensor, torch.Tensor]:
ids = self.tokenizer.encode(prompt, add_special_tokens=False)[-self.context_tokens:]
mask = torch.ones((1, len(ids)), dtype=torch.bool, device=self.device)
with torch.inference_mode():
inp = torch.tensor([ids], dtype=torch.long, device=self.device)
hidden = self.model.model(input_ids=inp).last_hidden_state.to(torch.float16)
return hidden, mask
def encode_options(self, descriptions: list[str]) -> torch.Tensor:
vectors = []
with torch.inference_mode():
for description in descriptions:
ids = self.tokenizer.encode(description, add_special_tokens=True)[: self.option_tokens]
if not ids:
raise ValueError("empty option tokens")
inp = torch.tensor([ids], dtype=torch.long, device=self.device)
hidden = self.model.model(input_ids=inp).last_hidden_state.float()
vectors.append(hidden[0].mean(dim=0))
return torch.stack(vectors).to(torch.float16).unsqueeze(0)
def score(self, state: str, instructions: str, option_descriptions: list[str]) -> list[float]:
payload = {"evidence": state, "criterion": instructions, "options": [{"letter": LETTERS[i], "description": description} for i, description in enumerate(option_descriptions)]}
prompt = self.tokenizer.apply_chat_template([{"role": "system", "content": DIRECT_SYSTEM}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}], tokenize=False, add_generation_prompt=True, enable_thinking=False)
context, context_mask = self.encode_context(prompt)
options = self.encode_options(option_descriptions)
option_mask = torch.zeros((1, options.shape[1]), dtype=torch.bool, device=self.device)
option_mask[0, :] = True
with torch.inference_mode():
logits = self.head(context, context_mask, options, option_mask)[0]
probs = F.softmax(logits, dim=-1).cpu().numpy().tolist()
return probs
NOUL_YES = "The criterion holds"
NOUL_NO = "The criterion does not hold"
def question_spec(question: dict) -> tuple[str, list[str]]:
"""把 Jev 三类型问题归一为 (instructions, option_descriptions)。"""
question_type = question.get("type")
instructions = str(question.get("instructions", "")).strip()
if not instructions:
raise ValueError("missing instructions")
if question_type == "noul":
return instructions, [NOUL_YES, NOUL_NO]
if question_type == "choice":
criteria = question.get("criteria")
if not isinstance(criteria, dict) or not criteria:
raise ValueError("choice requires non-empty criteria dict")
return instructions, [str(value) for value in criteria.values()]
if question_type == "score":
criteria = question.get("criteria")
if not isinstance(criteria, list) or len(criteria) < 2:
raise ValueError("score requires ordered criteria list (>=2 levels)")
return instructions, [str(item) for item in criteria]
raise ValueError(f"unknown type {question_type}")
def answer_request(scorer, state: str, questions: dict) -> dict:
"""请求级入口:同 state 多问题走 score_batch(10.2 共享前缀 KV-Cache 广播)。"""
names = list(questions.keys())
specs = [question_spec(questions[name]) for name in names]
if hasattr(scorer, "score_batch"):
prob_lists = scorer.score_batch(state, specs)
else:
prob_lists = [scorer.score(state, instructions, option_descriptions) for instructions, option_descriptions in specs]
answers = {}
for name, question, (instructions, _), probs in zip(names, [questions[n] for n in names], specs, prob_lists):
answers[name] = format_answer(question, probs)
return answers
def format_answer(question: dict, probs: list[float]) -> dict:
question_type = question.get("type")
if question_type == "noul":
p_yes = float(probs[0])
return {"noul": p_yes, "confidence": max(p_yes, 1.0 - p_yes), "probabilities": {"yes": p_yes, "no": 1.0 - p_yes}}
if question_type == "choice":
criteria = question["criteria"]
ids = list(criteria.keys())
best = int(np.argmax(probs))
return {"choice": ids[best], "confidence": float(probs[best]), "probabilities": {key: float(p) for key, p in zip(ids, probs)}}
if question_type == "score":
criteria = question["criteria"]
k = len(criteria)
expectation = sum(p * (i / (k - 1)) for i, p in enumerate(probs))
return {"score": float(expectation), "confidence": float(max(probs)), "probabilities": {str(item): float(p) for item, p in zip(criteria, probs)}}
raise ValueError(f"unknown type {question_type}")
def answer_question(scorer, state: str, name: str, question: dict) -> dict:
question_type = question.get("type")
instructions = str(question.get("instructions", "")).strip()
if not instructions:
raise ValueError(f"question {name} missing instructions")
if question_type == "noul":
probs = scorer.score(state, instructions, [NOUL_YES, NOUL_NO])
p_yes = float(probs[0])
return {"noul": p_yes, "confidence": max(p_yes, 1.0 - p_yes), "probabilities": {"yes": p_yes, "no": 1.0 - p_yes}}
if question_type == "choice":
criteria = question.get("criteria")
if not isinstance(criteria, dict) or not criteria:
raise ValueError(f"question {name} choice requires non-empty criteria dict")
ids = list(criteria.keys())
descriptions = [str(criteria[key]) for key in ids]
probs = scorer.score(state, instructions, descriptions)
best = int(np.argmax(probs))
return {"choice": ids[best], "confidence": float(probs[best]), "probabilities": {key: float(p) for key, p in zip(ids, probs)}}
if question_type == "score":
criteria = question.get("criteria")
if not isinstance(criteria, list) or len(criteria) < 2:
raise ValueError(f"question {name} score requires ordered criteria list (>=2 levels)")
descriptions = [str(item) for item in criteria]
probs = scorer.score(state, instructions, descriptions)
k = len(descriptions)
expectation = sum(p * (i / (k - 1)) for i, p in enumerate(probs))
return {"score": float(expectation), "confidence": float(max(probs)), "probabilities": {description: float(p) for description, p in zip(descriptions, probs)}}
raise ValueError(f"question {name} unknown type {question_type}")
SELFTEST_SCENARIOS = [
{"name": "support ticket routing", "state": "Hi, I've been trying to connect my Stripe account for 3 days and the integration keeps failing. I'm losing sales. Please help ASAP.", "questions": {"is_urgent": {"type": "noul", "instructions": "The message conveys urgency or time-sensitivity"}, "department": {"type": "choice", "instructions": "Which team should handle this", "criteria": {"billing": "Payment or subscription issues", "technical": "Bugs or integration problems", "sales": "Pricing or account questions"}}, "frustration": {"type": "score", "instructions": "How frustrated the customer appears", "criteria": ["Calm, just stating facts", "Frustrated but civil", "Very angry, strong language"]}}},
{"name": "financial news risk", "state": "The Federal Reserve signaled it will hold interest rates steady this quarter, while inflation remains above target. Tech stocks fell 2% in pre-market trading.", "questions": {"is_bearish": {"type": "noul", "instructions": "The headline is overall negative for equity markets"}, "asset_class": {"type": "choice", "instructions": "Which asset class is most directly affected", "criteria": {"equities": "Stocks", "rates": "Bonds / interest rates", "crypto": "Cryptocurrencies", "fx": "Currencies"}}, "impact": {"type": "score", "instructions": "How large is the expected market impact", "criteria": ["Negligible", "Moderate", "Major"]}}},
{"name": "phishing email", "state": "Dear Customer, your account will be suspended in 24 hours unless you verify your password immediately by clicking this link: http://secure-login.xyz/verify", "questions": {"is_spam": {"type": "noul", "instructions": "This is unsolicited bulk or promotional email"}, "is_phishing": {"type": "noul", "instructions": "This email tries to trick the user into revealing credentials or clicking a malicious link"}, "risk": {"type": "choice", "instructions": "The primary risk category", "criteria": {"credential_theft": "Asks for login credentials or passwords", "malware": "Encourages downloading or running something", "scam": "Asks for money or personal data", "benign": "No clear malicious intent"}}}},
{"name": "code review", "state": "def divide(a, b):\n return a / b\n\n# used as: divide(10, 0)", "questions": {"has_bug": {"type": "noul", "instructions": "The code has a correctness bug that can crash or produce wrong results"}, "bug_type": {"type": "choice", "instructions": "The main defect, if any", "criteria": {"division_by_zero": "Does not guard against divide-by-zero", "type_error": "Wrong types or misuse", "logic_error": "Wrong logic / off-by-one", "none": "No clear defect"}}, "readability": {"type": "score", "instructions": "How readable and well-structured the code is", "criteria": ["Poor", "Okay", "Clean"]}}},
{"name": "review scoring", "state": "The laptop is fast and the screen is gorgeous, but the battery only lasts 3 hours and the price is way over what I expected. Would buy it again for the performance.", "questions": {"performance": {"type": "score", "instructions": "How well performance is reviewed", "criteria": ["Poor", "Average", "Excellent"]}, "battery": {"type": "score", "instructions": "How well battery life is reviewed", "criteria": ["Poor", "Average", "Excellent"]}, "value_for_money": {"type": "score", "instructions": "How well price/value is reviewed", "criteria": ["Poor", "Average", "Excellent"]}, "would_recommend": {"type": "noul", "instructions": "The reviewer would recommend this product"}}},
]
def run_selftest(scorer, tokenizer) -> None:
for scenario in SELFTEST_SCENARIOS:
started = time.time()
answers = answer_request(scorer, scenario["state"], scenario["questions"])
latency = round((time.time() - started) * 1000, 1)
print("=" * 70)
print(f"【{scenario['name']}】latency={latency}ms")
print(json.dumps(answers, ensure_ascii=False, indent=2))
class Handler(BaseHTTPRequestHandler):
scorer = None
tokenizer = None
model_name = "semif-systemone"
def do_POST(self):
if self.path != "/v1/systemone":
self.send_json(404, {"error": f"unknown path {self.path}"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length).decode("utf-8"))
state = str(body.get("state", ""))
questions = body.get("questions")
if not state or not isinstance(questions, dict) or not questions:
raise ValueError("body requires state and questions")
started = time.time()
answers = answer_request(self.scorer, state, questions)
latency = round((time.time() - started) * 1000, 1)
input_tokens = sum(len(self.tokenizer.encode(json.dumps({"state": state, "question": question}, ensure_ascii=False), add_special_tokens=False)) for question in questions.values())
self.send_json(200, {"answers": answers, "model": self.model_name, "usage": {"input_tokens": input_tokens, "output_tokens": 0}, "latency_ms": latency})
except Exception as error:
self.send_json(400, {"error": f"{type(error).__name__}: {error}"})
def send_json(self, status: int, payload: dict) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
print(f"[{time.strftime('%H:%M:%S')}] {args[0]}", flush=True)
def load_lora_checkpoint(model, checkpoint: Path):
"""把 scale_lora_train 保存的 lora_state merge 进基座(merge_and_unload 后即普通模型)。"""
from peft import LoraConfig, get_peft_model
payload = torch.load(checkpoint, map_location="cpu", weights_only=False)
lora_state = payload["lora_state"]
config = payload.get("config", {})
rank = int(config.get("rank", 32))
lora_config = LoraConfig(r=rank, lora_alpha=rank * 2, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.0, bias="none", task_type="CAUSAL_LM")
wrapped = get_peft_model(model, lora_config)
wrapped.load_state_dict(lora_state, strict=False)
current = wrapped.state_dict()
matched = sum(1 for name, tensor in lora_state.items() if name in current and current[name].shape == tensor.shape)
print(f"lora checkpoint {checkpoint}: {len(lora_state)} tensors, matched={matched}", flush=True)
if matched < len(lora_state) * 0.9:
raise ValueError(f"lora state mismatch: matched {matched}/{len(lora_state)}")
wrapped.eval()
return wrapped.merge_and_unload()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=str, default=os.path.expanduser("~/modelscope/models/Qwen--Qwen3.5-4B/snapshots/master"))
parser.add_argument("--port", type=int, default=8100)
parser.add_argument("--mode", choices=("direct", "lora", "head"), default="direct")
parser.add_argument("--lora-checkpoint", type=Path, default=None)
parser.add_argument("--head-checkpoint", type=Path, default=None)
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu"))
print(f"loading {args.model} on {device} (mode={args.mode})", flush=True)
model, tokenizer, _ = load_torch(args.model)
if device.type == "cuda":
model = model.to("cuda:0")
if args.mode == "lora":
if args.lora_checkpoint is None:
parser.error("--mode lora requires --lora-checkpoint (scale_lora_train 产物)")
model = load_lora_checkpoint(model, args.lora_checkpoint)
scorer = DirectScorer(model, tokenizer, device)
Handler.model_name = "semif-systemone-lora"
elif args.mode == "head":
if args.head_checkpoint is None:
parser.error("--mode head requires --head-checkpoint (scale_head_train 产物)")
scorer = HeadScorer(model, tokenizer, device, args.head_checkpoint)
Handler.model_name = "semif-systemone-head"
else:
scorer = DirectScorer(model, tokenizer, device)
Handler.scorer = scorer
Handler.tokenizer = tokenizer
if args.selftest:
run_selftest(scorer, tokenizer)
return
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"listening on 0.0.0.0:{args.port} (POST /v1/systemone)", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()