跳转到内容

2026 睿抗 CAIP 大模型及智能体应用赛项决赛样题答案与备赛大观

按任务梳理 2026 睿抗 CAIP 大模型及智能体应用决赛样题的基础知识与 94 个填空的参考答案,覆盖 Conda、Transformers、LoRA、RAG、缓存降级与多智能体安全。

睿抗CAIP强脑赛道

使用说明

这份答案是备赛时对照用的,不是标准答案。我的建议是:先背题目里的代码框架和指定参数,再回头看“基础知识”把概念对上;最后自己填一遍,展开答案对照。解析不只给结果,也说明为什么这么填,方便你回头补对应的知识点。

样题没有官方答案。下面这些内容是按题面、项目结构和公开文档推出来的;题面没给、只能靠附件常量判断的空,我标了“按题面推断”,用公开文档核对过的标了“网络核对”。如果你的附件和这里不一致,以附件为准。

  • ※:题面没给常量,按上下文和常见实现推断。
  • †:用公开文档核对过(Hugging Face / ModelScope / Qwen 文档)。
  • 代码答案只写空缺本身;字符串答案按题面约定可以省略引号。

资料索引

资料适合查什么本卷对应
ai-agent-interview-guideAgent 基础、RAG、工具调用、多智能体、工程化任务 5–10 的概念索引
Hello-Agents第 4、8、9、12 章:ReAct、记忆与 RAG、上下文工程、评估任务 2、5、6、9、10
Hands-on-AA第 5、6、10 章:智能体架构、记忆/RAG、LoRA任务 4、5、6
dive-into-llms第 1、2、6、10 章:微调、提示学习、越狱、智能体安全任务 1–4、10
agent-interview-hubRAG、多 Agent、Function Calling 专题任务 6–10
菜鸟教程 AI AgentAgent 组成、Token、RAG、多智能体、工具调用、评估与部署的入门解释任务 5、6、8–10 的通俗背景

读法:菜鸟教程看概念,Hello-Agents 补机制,两个面试仓库查工程细节,dive-into-llms / Hands-on-AA 看微调和安全实验。章节号以仓库当前目录为准;Hands-on-AA 是 notebook,路径形如 part_2/chapter_5。

面试八股文的入口在 docs/01-面试八股文/README.md。

任务地图

模块任务关键词
模型层1 开发环境核验Conda、CPU wheel、依赖、日志
模型层2 大模型服务部署ModelScope、Transformers、FastAPI、生成参数
模型层3 日志清洗与样本构建正则脱敏、数据清洗、9:1、messages
模型层4 LoRA 微调PEFT、秩、目标模块、TrainingArguments
应用层5 对话与摘要多轮会话、角色、token、CLI
应用层6 政策知识 RAG切块、Embedding、FAISS、阈值
应用层7 性能与体验优化缓存、TTL、降级、性能字段
智能体层8 工单角色设计Planner、Executor、QA、JSON 契约
智能体层9 多工具流程工具注册、JSONL、工具顺序
智能体层10 编排与安全规则路由、拦截、RBAC、审计

模块一:模型层:环境部署与 LoRA 微调

任务 1:开发环境核验与依赖检查

基础知识

Conda 环境就是一套独立的 Python 安装目录。建环境、激活、确认解释器位置:

/.../envs/raicom/bin/python
conda create -n raicom python=3.11 -y
conda activate raicom
which python
python -c "import sys; print(sys.prefix)"

创建和激活用同一个环境名即可,名字只决定目录。提示符变了不算数,which python 指向 envs/raicom/bin/python 才算。

torch 的 CPU 版和 GPU 版共用一个包名,区别在 wheel:

  • 默认 PyPI 的 Linux wheel 通常带 CUDA 运行时,体积大;
  • --index-url https://download.pytorch.org/whl/cpu 从 CPU 索引取包,wheel 不链接 CUDA;
  • --index-url 会替换默认索引,不是追加。用内网镜像时先确认它同步了 whl/cpu。
终端窗口
pip install torch \
--index-url https://download.pytorch.org/whl/cpu
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
# 2.x.x+cpu False

requirements.txt 锁的是直接依赖。Trainer 的设备放置、混合精度、分布式启动由 accelerate 负责,缺它会在 import 或初始化时报 ModuleNotFoundError;peft 的训练入口也依赖它。碰到这个错先查训练依赖,别去翻模型文件。

日志要能作为提交证据。tee 只接 stdout,脚本把错误写到 stderr 时会漏:

终端窗口
mkdir -p logs
set -o pipefail
python env_check.py 2>&1 | tee logs/env_verify.log

tee 不会自动建父目录,mkdir -p 先执行。pipefail 用来防止 Python 失败而 tee 成功、整条命令仍返回 0。

几个容易忽略的点:

  • conda activate 提示未初始化:conda init bash,重开 shell。
  • 在 base 里 pip install:先确认 which python,或用 python -m pip。
  • Python 3.11 装不上老版本 torch:查版本矩阵,别直接升级。

代码说明

代码作用
conda create -n raicom python=3.11 -y建名为 raicom 的环境,指定 Python 3.11,-y 跳过确认
conda activate raicom激活环境,把它的 bin 目录放到 PATH 前面
which python查当前 python 的实际路径,确认没跑错环境
python -c "..."执行一段 Python 代码后退出,常用来做单行检查
pip install torch --index-url ...从指定索引安装 torch;行尾 \ 是换行续行
torch.__version__版本字符串,CPU wheel 常带 +cpu
torch.cuda.is_available()返回 True / False,CPU 环境应为 False
python env_check.py运行环境检查脚本
2>&1把 stderr 合并进 stdout,错误也会进日志
set -o pipefail管道里任一命令失败,整条命令返回非零

tee 和管道单独说:

  • | 是管道,把左边命令的 stdout 交给右边命令的 stdin。
  • tee logs/env_verify.log 把 stdin 同时写到屏幕和文件;默认覆盖,要追加用 tee -a。
  • python env_check.py 2>&1 | tee logs/env_verify.log 的完整含义:运行脚本,合并错误输出,再把全部输出一边打印一边写进日志。
  • tee 不会建目录,所以先 mkdir -p logs。日志要保留多次运行时用 tee -a:
终端窗口
python env_check.py 2>&1 | tee -a logs/env_verify.log

题目 1.1(填空题)

创建并激活 Conda 环境,请写出对应命令。

终端窗口
# 1. 创建名为 raicom 的 Python 环境
conda create -n __【1】__ python=3.11 -y
# 2. 激活该环境
conda activate __【2】__
点击查看答案

答案:

  • 【1】:raicom
  • 【2】:raicom

解析:Conda 用 -n 指定环境名,创建和激活必须同名,否则激活的是另一个环境;python=3.11 固定解释器版本。题面两空分别对应创建和激活同一条命令,所以都填 raicom。

题目 1.2(填空题)

安装 CPU 版 PyTorch,请写出安装命令中应使用的索引源(知识性考核,不需要实际执行)。

终端窗口
pip install torch --index-url __【3】__
点击查看答案

答案: https://download.pytorch.org/whl/cpu

解析:PyTorch 的 CPU wheel 单独发布在 whl/cpu 索引;—index-url 是替换默认索引,不是追加。题面明确要求 CPU 版且只留一个索引参数,所以填 https://download.pytorch.org/whl/cpu。

题目 1.3(填空题)

附件 requirements.txt 中缺少一个 LoRA 微调必需的关键依赖(缺少它训练会报 ModuleNotFoundError),请补全依赖名。

transformers==4.51.3
peft==0.15.2
modelscope==1.25.0
datasets==3.5.0
sentence-transformers==3.4.1
faiss-cpu==1.10.0
fastapi==0.115.12
uvicorn==0.34.2
requests==2.32.3
# 请补充缺少的依赖:
__【4】__
点击查看答案

答案: accelerate (网络核对)

解析:Trainer 的设备放置、混合精度、分布式启动由 Accelerate 负责,缺它时训练入口在 import/初始化阶段就报 ModuleNotFoundError;peft 的训练路径也依赖它。题面说“LoRA 微调必需、缺了会报 ModuleNotFoundError”,指向训练依赖而不是模型文件,所以填 accelerate。(网络核对:Hugging Face Transformers 文档)

题目 1.4(填空题)

运行环境校验脚本(附件 env_check.py),其日志会写入固定的落盘目录(相对于后端工程根目录)。请补全该目录。

终端窗口
pip install -r requirements.txt
python env_check.py | tee __【5】__/env_verify.log
点击查看答案

答案: logs (按题面推断)

解析:tee 把 stdin 同时写到屏幕和文件,但不会创建父目录。题面说日志落在“相对于后端工程根目录的固定目录”,结合项目约定是 logs/;先 mkdir -p logs,tee logs/env_verify.log 才能成功。

任务 2:大模型服务部署与接口配置

基础知识

一次生成拆成三步:

inputs = tokenizer(prompt, return_tensors="pt")
out = model.generate(**inputs, max_new_tokens=256, do_sample=False)
text = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True)
  • tokenizer 负责文本 ↔ token id;
  • model 根据 id 算下一个 token 的概率;
  • decode 把新生成的 id 变回文本。切片 [输入长度:] 是为了丢掉 prompt,skip_special_tokens=True 去掉 <|im_end|> 这类标记。

model.eval() 关闭 dropout。推理时再加 torch.inference_mode()(或 no_grad()),不然中间激活会一直占内存。

模型仓库 ID 是“组织/模型”,例如 Qwen/Qwen3-0.6B。--local_dir 只决定文件放哪,不改变模型身份。本地目录至少要有 config.json、权重、tokenizer.json、generation_config.json;缺 tokenizer 时 from_pretrained 会直接报错。torch_dtype=torch.float32 在 CPU 上最稳,float16 / bfloat16 要看硬件。

Qwen 的对话格式由模板生成,不要手拼:

prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)

add_generation_prompt=True 会在末尾补 assistant 的起始标记;不加,模型可能把整段当普通文本续写。enable_thinking=False 跳过 Qwen3 的思考段,CPU 上能少生成不少 token。

参数作用本题
max_new_tokens新生成的 token 上限,不含输入256
do_sampleFalse 走贪心,结果可复现False
temperature采样温度,仅 do_sample=True 时生效不设
top_p核采样,仅采样时生效不设

生成参数不止这几个,常见的还有:

参数作用
top_k只在概率最高的 k 个 token 里采样,和 top_p 二选一或一起用
repetition_penalty惩罚已出现的 token,减轻复读
presence_penalty / frequency_penaltyOpenAI 风格:按“是否出现 / 出现次数”惩罚
stop / eos_token_id遇到指定字符串或 token 就停
stream流式返回,边生成边推送

不同接口的参数名不完全一样:

目的HF generateOpenAI Chat Completions
输出上限max_new_tokensmax_tokens / max_completion_tokens
是否采样do_sample没有这个开关,靠 temperature / top_p
温度temperaturetemperature
核采样top_ptop_p
停止eos_token_id / stop_stringsstop
流式TextIteratorStreamerstream: true

注意 do_sample=False 时 temperature、top_p 不生效,HF 直接走贪心;OpenAI 兼容接口通常用 temperature=0 近似贪心,但不同厂商实现有差异。

请求格式也有差别:

厂商/格式system 放哪对话主体
OpenAI Chat Completionsmessages 里 role: "system"messages
Anthropic Messages顶层 system 字段messages
Gemini generateContent无独立 system 字段,通常放首条 usercontents → parts

比赛用 OpenAI/Qwen 格式,别把 system 提到顶层,也别用 contents / parts。

FastAPI 路由:

@app.post("/v1/chat/completions")
async def chat(req: Request):
body = await req.json()
...

@app.post 只接受 POST;req: Request 适合消息体不固定的场景,结构固定时用 Pydantic 更稳。host="0.0.0.0" 监听所有网卡,127.0.0.1 只允许本机,端口要和评测脚本一致。

注意 async def 里直接调 model.generate 会阻塞事件循环。CPU 推理本来就慢,并发请求会被排队;要么把路由写成普通 def(FastAPI 放到线程池),要么用 run_in_threadpool,线上再加队列和超时。

接口测试:

终端窗口
URL=http://127.0.0.1:8700/v1/chat/completions
curl -s -X POST "$URL" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"你好"}]}' \
-o submission/m1/api_test_result.json \
-w "%{http_code}"

200 表示请求成功且业务正常;4xx 查请求参数、路径、鉴权;5xx 查服务端。连接被拒绝多半是端口没起,超时则是服务在但处理太慢。

代码说明

代码作用
tokenizer(prompt, return_tensors="pt")文本转 token id,返回 PyTorch 张量字典
**inputs把字典拆成关键字参数,等价于 input_ids=..., attention_mask=...
model.generate(...)自回归生成,返回包含输入和新 token 的完整序列
out[0]取 batch 里第一条序列
out[0][n:]切掉前 n 个输入 token,只留新生成部分
tokenizer.decode(..., skip_special_tokens=True)id 转文本,去掉 im_end 等特殊标记
torch.inference_mode()关闭梯度记录,省内存
messages消息列表,元素是 {"role": ..., "content": ...}
add_generation_prompt=True末尾补 assistant 起始标记
enable_thinking=FalseQwen3 关闭思考段

OpenAI 对话 JSON 的解析:

import json
raw = '{"messages":[{"role":"user","content":"你好"}]}'
body = json.loads(raw) # 字符串 -> dict
messages = body["messages"] # list
print(messages[0]["role"]) # user
print(messages[0]["content"]) # 你好
  • json.loads 只接受字符串;body 是普通 Python dict/list。
  • body["messages"] 取列表,messages[0] 取第一条,["role"] 取角色。
  • 反过来用 json.dumps(obj) 把 dict 转成 JSON 字符串。
  • JSON 的键和字符串必须双引号;Python dict 可以单引号,两者不能混。

FastAPI 里 await req.json() 做的就是 json.loads 这一步:把请求体读成 dict。

curl 参数:

参数作用
-s静默模式,不显示进度
-X POST指定 HTTP 方法
-H "Content-Type: application/json"声明请求体是 JSON
-d '...'发送请求体
-o 文件把响应体写到文件
-w "%{http_code}"只打印状态码

题目 2.1(填空题)

比赛指定模型已就位(目录 ./models/Qwen3-0.6B)。请写出应使用的模型仓库 ID。

终端窗口
modelscope download --model __【6】__ --local_dir ./models/Qwen3-0.6B
点击查看答案

答案: Qwen/Qwen3-0.6B (网络核对)

解析:ModelScope 的仓库 ID 是“组织/模型”两段式;—local_dir 只决定下载到哪,不改变模型身份。题面给的本地目录是 ./models/Qwen3-0.6B,对应官方仓库 Qwen/Qwen3-0.6B,所以填它。(网络核对:ModelScope 模型页)

题目 2.2(填空题)

编写推理服务 serve_model.py,将模型部署为 RESTful 服务。请补充代码空缺(生成参数按本题指定:长度上限 256、关闭采样)。

import torch, uvicorn
from fastapi import FastAPI, Request
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_PATH = "./models/Qwen3-0.6B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
model = AutoModelForCausalLM.__【7】__(MODEL_PATH, torch_dtype=torch.float32)
model.eval()
app = FastAPI()
@app.__【8】__("/v1/chat/completions")
async def chat(req: Request):
messages = (await req.json())["messages"]
prompt = tokenizer.__【9】__(
messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(prompt, return_tensors="pt")
out = model.generate(**inputs, max_new_tokens=__【10】__, do_sample=__【11】__)
...
uvicorn.run(app, host="0.0.0.0", port=8700)
点击查看答案

答案:

  • 【7】:from_pretrained
  • 【8】:post
  • 【9】:apply_chat_template
  • 【10】:256
  • 【11】:False

解析:五个空对应推理服务的五个必要环节:from_pretrained 读权重和配置;@app.post 注册 POST 路由;apply_chat_template 按训练格式拼对话;题面指定长度上限 256,所以 max_new_tokens=256;题面要求关闭采样,do_sample=False 走贪心。

题目 2.3(填空题)

用 curl 完成接口连通性测试。请补充端口号与预期 HTTP 状态码。

终端窗口
curl -s -X POST http://127.0.0.1:__【12】__/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"你好"}]}' \
-o submission/m1/api_test_result.json -w "%{http_code}" # 预期状态码 __【13】__
点击查看答案

答案:

  • 【12】:8700
  • 【13】:200

解析:端口要和架构表一致,模型层是 :8700;请求体是合法 JSON、路由存在、服务正常时返回 200。题面用 -w ”%{http_code}” 只取状态码,所以填 8700 和 200。

题目 2.4(填空题)

Qwen3 支持“思考模式”。本卷要求在 CPU 上关闭思考模式。请写出关闭时所传的参数值(填 True 或 False)。

prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
enable_thinking=__【14】__) # CPU 环境关闭思考以提速
点击查看答案

答案: False (网络核对)

解析:Qwen3 的 chat template 支持 enable_thinking;开启后先输出思考段再给答案,关闭则跳过。题面明确说“CPU 环境关闭思考以提速”,所以填 False。(网络核对:Qwen 官方 Quickstart)

任务 3:咨询日志清洗与微调样本构建

基础知识

日志里的手机号、测试会话、空回答、重复提问都会进训练集。清洗的目标是让训练分布贴近真实咨询。

手机号按大陆号段匹配:

import re
PHONE_RE = re.compile(
r"(?<![0-9])1[3-9][0-9]{9}(?![0-9])")
record["phone"] = PHONE_RE.sub("***", record["phone"])
  • 1[3-9] 限定首位和第二位号段,后面 9 位数字;
  • [0-9] 比 \d 严格,避免匹配 Unicode 数字;
  • 前后 lookaround 防止从更长的数字串中间截一段;
  • 占位符固定为 *** 或 [PHONE],不要保留后四位。

清洗条件写成代码:

valid = [
r for r in data
if not r["session_id"].startswith("test")
and r["turns"]
and all(t["a"].strip() for t in r["turns"])
]

all([]) 返回 True,所以没有 turns 的记录要单独用 r["turns"] 判空。重复样本按会话或内容哈希去重。

目标规则容易出错
去测试数据session_id 前缀前缀过宽会误删真实会话
去空回答strip() 后非空all([]) 为真
去重会话/内容哈希只按问题去重会误删合理复问
脱敏正则替换过宽/过窄都会漏或误伤

划分前先清洗、去重,再按 9:1 切:

n_train = int(len(valid) * 0.9)
train, val = valid[:n_train], valid[n_train:]

顺序反了,同一通会话可能同时落在两边,验证集分数会虚高。划分前固定随机种子打乱;数据量很小时验证集只有几条,指标波动大,改用交叉验证或补数据。

SFT 样本用 messages:

{"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]}

role 只有 system、user、assistant(工具调用还有 tool)。训练时 loss 只算 assistant 的 token:prompt 段和 padding 的 labels 置 -100,否则模型会学复述问题。训练和推理用同一套 chat template,截断前先过滤超长样本,别把答案切掉。

代码说明

正则拆开看:

(?<![0-9]) 负向后顾:前一个字符不能是数字
1 手机号首位固定为 1
[3-9] 第二位 3 到 9
[0-9]{9} 后面正好 9 位数字
(?![0-9]) 负向前瞻:后一个字符不能是数字

re.compile(...) 预编译正则;re.sub("***", ...) 把所有匹配替换成 ***。

Python 代码:

代码作用
r["turns"]取列表;空列表在 if 里为假,顺手过滤空记录
t["a"].strip()去掉首尾空白;空字符串为假
all(...)所有元素都为真才返回 True;空迭代器返回 True
session_id.startswith("test")判断前缀,命中测试会话
int(len(valid) * 0.9)取 90% 的整数条数,向下取整
valid[:n_train]前 90%
valid[n_train:]剩下的 10%

OpenAI/Qwen 样本的 JSON 解析:

import json
raw = '{"messages":[{"role":"user","content":"你好"}]}'
obj = json.loads(raw)
for msg in obj["messages"]:
print(msg["role"], msg["content"])

messages 是列表,顺序就是对话顺序;每条只有 role 和 content 两个常用字段。json.loads 管“字符串 → 对象”,json.dumps 管“对象 → 字符串”。

题目 3.1(填空题)

对手机号做脱敏处理。请补充正则与占位符。

import re
PHONE_RE = re.compile(__【15】__) # 中国大陆手机号:11 位,号段 1[3-9] 开头
record["phone"] = PHONE_RE.sub(__【16】__, record["phone"])
点击查看答案

答案:

  • 【15】:1[3-9]\d{9}
  • 【16】:*** (按题面推断)

解析:大陆手机号 11 位:首位 1,第二位 3 到 9,后 9 位数字,正则是 1[3-9]\d{9}。re.sub 默认替换全部匹配;题面要求脱敏且不给可逆线索,占位符用固定的 ***。

题目 3.2(填空题)

过滤测试数据与空回答记录,并按 9:1 划分训练集与验证集。请补充两处空缺。

valid = [r for r in data
if not r["session_id"].startswith(__【17】__)
and all(t["a"].strip() for t in r["turns"])]
n_train = int(len(valid) * __【18】__) # 9:1 划分
点击查看答案

答案:

  • 【17】:test
  • 【18】:0.9

解析:startswith(“test”) 过滤测试会话;all(…) 要求每轮回答 strip() 后非空;int(len(valid) * 0.9) 取 90% 训练、10% 验证。题面明确 9:1,所以填 test 和 0.9。

题目 3.3(填空题)

将有效记录转换为指令微调标准格式(OpenAI/Qwen 对话格式)。请补充顶层字段名。

{"__【19】__": [{"role":"user","content":"..."},
{"role":"assistant","content":"..."}]}
点击查看答案

答案: messages

解析:OpenAI/Qwen 的指令微调样本用顶层 messages,值是 role / content 数组;多轮按 user/assistant 交替。题面给出的 JSON 骨架只有一个顶层键,所以填 messages。

任务 4:LoRA 小规模微调与效果验证

基础知识

全参数微调要更新所有权重,还要存梯度和优化器状态,0.6B 模型在 CPU 上基本跑不动。LoRA 冻结原权重,只训练低秩增量:

W' = W + (alpha / r) · B A
A: r × k B: d × r
可训练参数: r × (d + k) 原参数: d × k

A 随机初始化,B 置零,训练开始时 B A = 0,旁路不影响原模型。两个都置零会出事:B 有梯度,但 A 的梯度里带 B^T,恒为零,A 永远不更新。

Qwen3-0.6B 的实际尺寸:hidden 1024,16 个注意力头,8 个 KV 头,head_dim 128,28 层。q_proj 1024→2048,k_proj / v_proj 1024→1024,o_proj 2048→1024。取 r=8:

q_proj: 8×(1024+2048) = 24,576
k_proj: 8×(1024+1024) = 16,384
v_proj: 8×(1024+1024) = 16,384
o_proj: 8×(2048+1024) = 24,576
每层合计: 81,920
28 层: 2,293,760 ≈ 229 万
占 7.5 亿总参数: 约 0.3%

适配器 fp32 不到 10 MB;全参数微调光权重就是数 GB。内存不够时先量化基座:

精度0.6B 权重说明
FP32约 3 GBCPU 默认,最稳
FP16 / BF16约 1.5 GB需要硬件支持
INT8约 0.75 GB量化推理,精度略降
INT4约 0.4 GBQLoRA / 低显存

QLoRA = 4-bit 量化基座 + LoRA 旁路,训练时只更新旁路。量化能省内存,但 CPU 上不一定更快,取决于算子支持;比赛先保证 float32 能跑通。PEFT 的入口:

from peft import LoraConfig, get_peft_model
cfg = LoraConfig(
task_type="CAUSAL_LM", r=8, lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05)
model = get_peft_model(model, cfg)

target_modules 的名字必须和模型里的模块名一致。写错不会报错,只会没有可训练参数或效果很差;用 dict(model.named_modules()) 核对。只加 q_proj、v_proj 更省参数,四个都加表达能力强一些。

参数作用常用值
r秩,越大容量越大8
lora_alpha缩放,常取 2r 到 4r16
lora_dropout防过拟合0.05
learning_rateLoRA 可比全参微调大1e-4 到 2e-4
num_train_epochs遍历数据集的次数1
use_cpu强制 CPUTrue

CPU 训练先保证流程跑通、适配器能保存,再看 loss 和生成效果。训练完只存适配器:

model.save_pretrained("adapter")
# adapter_config.json + adapter_model.safetensors

加载时先加载基座,再 PeftModel.from_pretrained(base, "adapter");部署前可 merge_and_unload() 合回原权重。合并后不能再单独卸载,训练产物要留。

容易出错的地方:target_modules 写错;只存适配器忘了 tokenizer;训练/推理模板不一致;学习率过大导致 loss 震荡;CPU 上开 fp16 报错;device_map 和 use_cpu 冲突。

代码说明

代码作用
LoraConfig(...)定义旁路结构:任务类型、秩、缩放、目标模块、dropout
task_type="CAUSAL_LM"自回归语言模型
r=8低秩矩阵的秩
lora_alpha=16缩放系数,实际缩放约为 alpha/r
target_modules=[...]指定把旁路加到哪些线性层
get_peft_model(model, cfg)冻结原权重,按配置插入 LoRA 层
model.print_trainable_parameters()打印可训练参数占比,确认配置生效
TrainingArguments(...)训练超参:输出目录、轮数、批大小、学习率、日志间隔、设备
Trainer(...).train()执行训练循环
model.save_pretrained("adapter")只保存适配器权重和配置
PeftModel.from_pretrained(base, "adapter")在基座上加载适配器
merge_and_unload()把旁路合回原权重,得到普通模型

题目 4.1(填空题)

使用 PEFT 对 Qwen3-0.6B 做 LoRA 微调。请补充配置代码(秩按本题指定为 8)。

from peft import LoraConfig, get_peft_model
lora_cfg = __【20】__(task_type="CAUSAL_LM", r=__【21】__, lora_alpha=16,
target_modules=["q_proj","k_proj","v_proj","o_proj"],
lora_dropout=0.05)
model = __【22】__(model, lora_cfg)
点击查看答案

答案:

  • 【20】:LoraConfig
  • 【21】:8
  • 【22】:get_peft_model

解析:PEFT 用 LoraConfig 描述旁路:r 是秩、lora_alpha 是缩放、target_modules 指定加在 q/k/v/o 投影;get_peft_model 把配置注入模型并冻结原权重。题面指定秩为 8,所以三个空是 LoraConfig、8、get_peft_model。

题目 4.2(填空题)

为控制 CPU 训练时间,请补充训练参数(只训练 1 轮、在 CPU 上训练)。

training_args = TrainingArguments(
output_dir="submission/m1/finetune",
num_train_epochs=__【23】__,
per_device_train_batch_size=2,
learning_rate=2e-4,
logging_steps=10,
use_cpu=__【24】__)
点击查看答案

答案:

  • 【23】:1
  • 【24】:True

解析:num_train_epochs 控制遍历数据集的次数,题面要求只训练 1 轮;use_cpu=True 强制 CPU,题面明确 CPU 训练。CPU 上先保证流程跑通、适配器能保存,再谈效果,所以填 1 和 True。

模块二:应用层:馆内服务助手开发

任务 5:对话与摘要能力实现

基础知识

模型本身不记对话,历史放在服务进程里:

HISTORY = {}
history = HISTORY.setdefault(sid, [])
history.append({"role": "user", "content": text})
...
history.append({"role": "assistant", "content": answer})

setdefault 在键不存在时写入空列表并返回,省掉一次判断。每轮两条消息,user 一条、assistant 一条。

角色谁写放什么
system开发者行为约束、任务指令、输出格式
user用户问题、待处理的素材
assistant模型回复、工具调用前的思考
tool运行时工具返回结果

摘要接口把“请生成 100 字以内摘要”放 system,公告正文放 user。指令和素材混在一起,模型容易把指令也当成要摘要的内容。

历史不能一直追加。上下文有上限,token 越多越慢,注意力也会被稀释。滑动窗口保留最近 N 条:

MAX_HISTORY_MESSAGES = 4 # 2 轮
recent = history[-MAX_HISTORY_MESSAGES:]

上下文窗口是输入 + 输出共享的上限。max_new_tokens 只是输出预留,不是总预算。几个物理限制:

  • KV Cache 随序列长度线性增长,注意力计算随长度平方增长;长上下文更吃显存、更慢。
  • 长上下文会出现“中间遗忘”:关键信息在长文档中间更容易被漏掉,窗口大不等于都能记住。
  • 标称窗口不等于实际可用窗口,有的产品会用滑动窗口或截断,实际记忆更短。
  • 预算分配:system + 最近几轮 + 检索片段 + 输出预留;超了先压缩历史或减少检索块,别硬塞。

一轮两条,保留两轮就是 4 条。更稳的做法是 system 常驻 + 最近几轮原文 + 更早内容的摘要,摘要里保留任务状态、用户偏好和未解决问题。

max_new_tokens 的单位是 token,不是汉字。一个汉字可能对应多个 token,“100 字摘要”不能写成 max_new_tokens=100,256 也不保证输出 256 个汉字。按字符截断:

answer = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True)
if len(answer) > 128:
answer = answer[:128]

len 数的是 Unicode 码点,不是字节。解码时切掉输入部分,否则回复里会带上一整段提问。

摘要还有两个问题:公告太长要分段摘要再合并;模型可能编造公告里没有的内容,提示词里写“只依据给定公告,找不到就说不知道”,输出后再核对关键实体。

命令行联调:

text = input("请输入问题(q 退出):")
url = "http://127.0.0.1:8100/chat"
r = requests.post(url, json={"session_id": "cli", "text": text},
timeout=30).json()
print(r["reply"])

线上脚本要加 timeout 和异常处理,服务挂起时客户端不会一直等。

容易踩的坑:生成失败还把空回复写进历史;多请求共用一个 HISTORY 没加锁;历史无限增长;滑动窗口把 system 挤掉;async def 里同步调 generate。

代码说明

代码作用
HISTORY.setdefault(sid, [])键存在就返回原列表;不存在就写入空列表再返回
history.append({...})把一条消息追加到会话列表
history[-4:]取最后 4 条;负数下标从末尾数
tokenizer.decode(...)token id 转文本
out[0][n:]切掉输入,只留新生成
len(answer)字符数,不是 token 数
input("...")读一行命令行输入,返回字符串
requests.post(url, json=...)发 POST,json= 会自动序列化并设 Content-Type
.json()把响应体解析成 dict

请求和响应长这样:

{"session_id": "cli", "text": "借阅卡怎么补办?"}
{"session_id": "cli", "reply": "带学生证和照片到总服务台办理。"}

requests.post(..., json=payload) 等价于 data=json.dumps(payload) 再加 JSON 头;用 json= 更省事。.json() 内部就是 json.loads(response.text)。

题目 5.1(填空题)

实现多轮对话接口 /chat。请补充代码空缺(生成参数按本题指定:长度上限 256、关闭采样)。

HISTORY = {}
@app.__【25】__("/chat")
async def chat(req: Request):
body = await req.json()
sid, text = body["session_id"], body["text"]
history = HISTORY.__【26】__(sid, []) # 会话历史不存在则创建
history.append({"role": "user", "content": text})
prompt = tokenizer.apply_chat_template(history, tokenize=False,
add_generation_prompt=True, enable_thinking=False)
inputs = tokenizer(prompt, return_tensors="pt")
out = model.generate(**inputs, max_new_tokens=__【27】__, do_sample=__【28】__)
answer = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True)
history.append({"role": __【29】__, "content": answer})
return {"session_id": sid, "reply": answer}
点击查看答案

答案:

  • 【25】:post
  • 【26】:setdefault
  • 【27】:256
  • 【28】:False
  • 【29】:assistant

解析:@app.post 注册 POST;HISTORY.setdefault(sid, []) 在会话不存在时建空列表并返回;生成参数与前面一致,题面指定 256、关闭采样;回复由模型生成,角色是 assistant。所以五个空依次是 post、setdefault、256、False、assistant。

题目 5.2(填空题)

实现通知摘要接口 /summarize。摘要指令「请生成 100 字以内摘要」应放在 __【30】__ 角色的消息中(填英文角色名);其生成长度上限(本题指定)为 __【31】__。

messages = [
{"role": "__【30】__", "content": "请生成100字以内摘要"},
{"role": "user", "content": announcement},
]
out = model.generate(**inputs, max_new_tokens=__【31】__, do_sample=False)
点击查看答案

答案:

  • 【30】:system
  • 【31】:256 (按题面推断)

解析:system 承载行为约束和任务指令,user 放待处理素材。题面要求“请生成 100 字以内摘要”,这是指令,应放 system;生成上限沿用本题指定的 256,给 100 字留 token 余量。

题目 5.3(填空题)

编写命令行联调脚本 cli.py。请补充两个内置/库函数名。

while True:
text = __【32】__("请输入问题(输入q退出):")
if text == "q": break
r = requests.__【33】__("http://127.0.0.1:8100/chat",
json={"session_id": "cli", "text": text}).json()
print(r["reply"])
点击查看答案

答案:

  • 【32】:input
  • 【33】:post

解析:input() 读一行命令行输入;requests.post(url, json=…) 发 JSON。题面路由是 /chat 且只接受 POST,所以不能用 requests.get,两个空是 input 和 post。

题目 5.4(填空题)

max_new_tokens 的计数单位是 __【34】__(英文单词单数,含义为“词元/令牌”;提示:它并不等同于中文字数);若要在返回前统计摘要的字符数,可用的 Python 内置函数是 __【35】__。

out = model.generate(**inputs, max_new_tokens=128, do_sample=False)
answer = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True)
if __【35】__(answer) > 128: # 字符数校验
answer = answer[:128]
点击查看答案

答案:

  • 【34】:token
  • 【35】:len

解析:max_new_tokens 数的是 token,不是汉字;一个汉字可能对应多个 token。len() 数 Python 字符串的码点,能用来做字符数校验。题面问单位和统计字符数的内置函数,所以填 token 和 len。

题目 5.5(填空题)

多轮对话中 HISTORY[sid] 每轮写入两条消息(user 提问+assistant 回复)。若代码只保留最近 __【36】__ 条消息,即保留最近 2 轮上下文(填数字)。

MAX_HISTORY_MESSAGES = __【36】__ # 保留最近 2 轮
recent = history[-MAX_HISTORY_MESSAGES:]
点击查看答案

答案: 4

解析:每轮写入 user + assistant 两条消息;题面要求保留最近 2 轮,所以窗口是 2 × 2 = 4 条。

任务 6:政策知识接入与检索增强(RAG)

基础知识

RAG 先在文档里找证据,再把证据和问题一起交给模型。它适合知识有截止时间、私有文档不在训练集、回答需要引用来源的场景。RAG 不是长期记忆,也不是微调的替代品。

流程分两段:

  1. 离线:加载文档 → 清洗 → 切块 → 向量化 → 写索引。
  2. 在线:问题向量化 → 检索 Top-K → 可选重排 → 拼提示 → 生成 → 引用。

RAG、微调、长上下文不是三选一:

方式原理优点缺点
RAG运行时检索外部知识知识可更新、可溯源、成本低检索质量决定上限
微调用数据改权重风格/格式稳定,推理快成本高,知识会过时,可能遗忘
长上下文文档全塞进窗口实现简单token 贵,长文本容易漏信息

常见组合是 RAG 提供事实、微调管格式和风格、长上下文处理单次大文档。

RAG 的演进路线:

  • Naive RAG:检索 → 拼接 → 生成。
  • Advanced RAG:查询改写、向量 + BM25 混合检索、重排序、自适应检索。
  • Modular RAG:按需组合检索模块。
  • Graph RAG:用知识图谱补多跳关系。
  • Agentic RAG:由 Agent 决定何时检索、检索什么、要不要多轮检索。

切块大小是第一个取舍。块太大,噪声多,向量被主题稀释;块太小,定义、条件、指代被切断。按自然段 \n\n 切、每块不超过 500 字,是个能解释的基线;按标题层级切更好,章节路径可以放进元数据。重叠取块大小的 10%–20%,能缓解边界丢失,但会增加索引和重复召回。

嵌入用同一个模型处理查询和文档:

from sentence_transformers import SentenceTransformer
emb = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 512 维
vecs = emb.encode(chunks)

BGE 的检索任务通常给查询加指令前缀(如“为这个句子生成表示以用于检索相关文章:”),文档不加。是否加按模型卡和实测决定,但查询和文档的处理必须一致。模型不一致,向量不在一个空间,分数没有意义。

FAISS 建索引:

import faiss
faiss.normalize_L2(vecs)
index = faiss.IndexFlatIP(512)
index.add(vecs)
faiss.write_index(index, "submission/m2/rag/policy.index")

IndexFlatIP 算内积。内积等于余弦相似度有个前提:两个向量都做了 L2 归一化。查询向量也要归一化。index.ntotal 是向量条数,write_index / read_index 负责持久化。小政策库用 Flat 精确检索就够,百万级以上再考虑 IVF、HNSW。

余弦的定义:

cos(q, d) = (q · d) / (|q| |d|)

归一化后 |q| = |d| = 1,内积就是余弦。忘了归一化,内积会偏向长向量,阈值也跟着失效。

阈值用来拒答:

if best_score < threshold:
return {"answer": "未检索到相关政策资料,请咨询图书馆总服务台。"}

最高分低于阈值,说明知识库里没有足够相关的证据,别让模型自由发挥。阈值太高会误拒,太低会放过不相关内容,要拿正负样本调。更稳的是检索分数、重排分数和规则一起判断。

环节关键选择常见问题
切块段落/标题,500 字基线,10%–20% 重叠太大噪声多,太小切断语义
嵌入同一模型、同一语言、同一归一化查询和文档模型不一致
索引小库 Flat,大库 IVF/HNSW忘了归一化
检索Top-K + 阈值 + 可选重排K 太大引入噪声,阈值太死
生成证据分区、要求引用、允许拒答把检索结果当指令执行

索引只存向量,不存原文。切块列表和索引一起持久化,检索到下标后用它取文本。索引和切块要同版本更新,否则 ntotal 和 len(chunks) 对不上,取回的文本会错位。

评估分两段:检索看 Recall@K、MRR、命中率;生成看忠实度、答案相关性、引用是否正确。只测最终答案,分不清是没检索到,还是检索到了但生成错了。

代码说明

代码作用
SentenceTransformer("BAAI/bge-small-zh-v1.5")加载嵌入模型
emb.encode(chunks)批量编码,返回 (块数, 512) 的数组
faiss.normalize_L2(vecs)原地做 L2 归一化,让内积等于余弦
faiss.IndexFlatIP(512)建精确内积索引,维度必须等于向量维度
index.add(vecs)写入向量,顺序对应 chunks 下标
index.search(qvec, k)返回 D(分数)和 I(下标),形状都是 (1, k)
index.ntotal索引里的向量条数
faiss.write_index(index, path)索引落盘
faiss.read_index(path)从磁盘加载索引

检索结果这样取:

D, I = index.search(qvec, 3)
for score, idx in zip(D[0], I[0]):
print(round(float(score), 4), chunks[idx]["text"][:50])

D[0] 是第一条查询的分数,I[0] 是对应的切块下标。IndexFlatIP 返回的内积分数越大越相似;归一化后范围大致在 [-1, 1]。

题目 6.1(填空题)

对 docs/ 下 3 篇政策文档做切分,按自然段落切块且每块不超过 500 字。请补充默认参数与切分方式。

def split_doc(text: str, max_len=__【37】__):
blocks = text.split(__【38】__) # 按自然段落切分
...
点击查看答案

答案:

  • 【37】:500
  • 【38】:\n\n

解析:切块要在“噪声”和“语义完整”之间取舍。题面要求按自然段切、每块不超过 500 字,所以 max_len=500,text.split(“\n\n”) 按空行分段。

题目 6.2(填空题)

使用赛题指定中文嵌入模型向量化并建立 FAISS 索引。请补充模型 ID、归一化方法调用与索引维度。

from sentence_transformers import SentenceTransformer
import faiss
emb = SentenceTransformer("__【39】__")
vecs = emb.encode(chunks)
faiss.__【40】__(vecs) # 归一化,使内积等价余弦相似度
index = faiss.IndexFlatIP(__【41】__) # bge-small-zh-v1.5 向量维度
index.add(vecs)
点击查看答案

答案:

  • 【39】:BAAI/bge-small-zh-v1.5 (网络核对)
  • 【40】:normalize_L2
  • 【41】:512 (网络核对)

解析:BGE 中文小模型 BAAI/bge-small-zh-v1.5 输出 512 维;IndexFlatIP 算内积,只有先做 L2 归一化,内积才等于余弦相似度。题面指定该模型并要求内积等价余弦,所以三个空是模型 ID、normalize_L2、512。(网络核对:Hugging Face 模型卡)

题目 6.3(填空题)

实现检索增强问答。请补全检索块数(本题指定 3 个),并写出该技术的英文简称(三个字母,即“检索增强生成”)。

def rag_answer(question: str, top_k=__【42】__):
scores, ids = index.search(qvec, top_k)
contexts = [chunks[i]["text"] for i in ids[0]]
... # 检索结果拼入提示词再交由模型生成,该技术简称 __【43】__
点击查看答案

答案:

  • 【42】:3
  • 【43】:RAG

解析:题面指定检索 3 个块,所以 top_k=3;把检索结果拼进提示词再生成,这个技术的缩写是 RAG。两个空分别对应“检索块数”和“技术简称”。

题目 6.4(填空题)

持久化索引与切分块,文件名按提交规范命名。请补充索引文件名;并在保存前用断言保证“向量条数=切分块数”,该内建属性名是 index.__【45】__。

faiss.write_index(index, "submission/m2/rag/__【44】__")
assert index.__【45】__ == len(chunks), "向量条数与切分块数不一致"
点击查看答案

答案:

  • 【44】:policy.index
  • 【45】:ntotal

解析:index.ntotal 是索引里的向量条数,用来断言等于切块数;题面要求按提交规范命名,索引文件是 policy.index。所以填 policy.index 和 ntotal。

题目 6.5(填空题)

要使测试问“借阅卡补办需要什么材料?”的检索回答同时含“学生证”与“照片”两个关键词,检索应命中的知识文档是 docs/ 目录下的 __【46】__(文件名含扩展名)。

question = "借阅卡补办需要什么材料?"
answer = rag_answer(question) # 命中文档:docs/__【46】__
点击查看答案

答案: 借阅卡补办.md (按题面推断)

解析:问题问“借阅卡补办需要什么材料”,应命中讲补办流程的文档;该文档包含“学生证”和“照片”。题面限定 docs/ 下的文件名,按上下文取 借阅卡补办.md,实际以附件目录为准。

题目 6.6(填空题)

超纲问题(如“明天天气怎么样?”)应友好拒答:当检索最高得分低于阈值时直接返回拒答文案。请补充判断条件中的比较运算符,以及加载已持久化索引的方法名。

threshold = 0.5
best_score = float(scores[0][0])
if best_score __【47】__ threshold: # 低于阈值则拒答
return {"answer": "未检索到相关政策资料,请咨询图书馆总服务台。"}
...
index = faiss.__【48】__("submission/m2/rag/policy.index")
点击查看答案

答案:

  • 【47】:<
  • 【48】:read_index

解析:题面要求最高分低于阈值时拒答,所以条件是 best_score < threshold;同时要求加载已持久化的 FAISS 索引,用 faiss.read_index,不要重新建 IndexFlatIP。

任务 7:应用性能与用户体验优化

基础知识

缓存的键由问题内容决定,不要只用会话 id:

key = hashlib.md5(question.encode("utf-8")).hexdigest()
if key in CACHE and time.time() - CACHE[key][0] < TTL:
return CACHE[key][1]
...
CACHE[key] = (time.time(), answer)

命中要同时满足两个条件:键存在,且写入时间在 TTL 内。写入和读取用同一个 key,否则永远命中不了。

键里要带上影响答案的所有因素:规范化后的问题、租户/权限范围、模型版本、提示词版本、检索库版本。只哈希问题,模型或知识库更新后会返回旧答案。问题先做规范化(去首尾空白、统一大小写、全角转半角、去掉无意义标点),不然“借阅卡补办”和“借阅卡补办?”是两个键。

md5 不是加密算法,抗碰撞性早被攻破。做缓存键够用;键会被外部构造或需要防篡改时,换 sha256。hexdigest() 得到十六进制字符串,digest() 是 bytes。

策略作用风险
精确哈希缓存命中直接返回,零推理成本问题改写后不命中
语义缓存相似问题也能命中阈值低会返回错答案
TTL限制答案过期时间太短命中率低,太长不新鲜
LRU/容量上限防止内存无限增长淘汰热点键会抖动
主动失效知识库更新后清理需要版本号或事件通知

错误响应、空答案、个性化内容不要缓存。高并发下防缓存击穿:同一个键同时未命中,多个请求一起回源;用单飞(single-flight)或分布式锁,只让一个请求生成,其余等结果。

模型服务超时、5xx、限流或熔断时走降级:

except Exception:
return JSONResponse(
content={"reply": "系统繁忙,请稍后重试。", "fallback": True},
status_code=200)

这里用 200,表示业务已经处理了异常;用 5xx 会让客户端重试、监控报警,反而放大故障。fallback: true 让调用方知道这不是正常回答。

熔断和限流别混:

  • 429 是限流,按 Retry-After 或指数退避重试,加随机抖动,别立刻打满。
  • 熔断器三态:Closed 正常放行;Open 直接失败、不再请求下游;Half-Open 放少量探测,连续成功再回 Closed。
  • 重试只针对可恢复错误(超时、429、5xx);4xx 参数错误重试也没用。

但 200 不是无条件的。完全给不出有意义的结果、客户端应该重试时,用 503 加 Retry-After 更诚实。降级和故障要分开统计。

性能脚本对比 off / on 两组,字段名通常写成 avg_ms_off / avg_ms_on。同一问题连续请求 n 次,第 1 次未命中,第 2 次开始命中,所以“从第几次起全部命中”是 2。--n 的默认值看 argparse 配置,题面显式传 15 不代表默认是 15。

只报平均值不够。平均值会被少数慢请求拉高,也掩盖长尾;至少看 p50、p95、p99,区分冷启动和热缓存。测速固定输入、输出长度、并发度和硬件。

返回体字段是契约:摘要字符数 char_count、缓存命中 cache_hit、相似度列表 scores。字段名定下后,前端和评测脚本都按它取值,改字段名要版本化。

代码说明

代码作用
question.encode("utf-8")字符串转 bytes,哈希函数只收 bytes
hashlib.md5(...)建 MD5 哈希对象
.hexdigest()取 32 位十六进制字符串,适合做字典键
time.time()当前时间戳,单位秒,浮点数
CACHE[key] = (time.time(), answer)值存“写入时间 + 答案”
time.time() - CACHE[key][0] < TTL判断是否过期
JSONResponse(content=..., status_code=...)同时指定响应体和状态码
except Exception:捕获所有异常,走降级分支

md5(...).hexdigest() 可以拆成三步:encode 转 bytes → md5 算摘要 → hexdigest 转十六进制字符串。缓存里存 tuple,[0] 是时间,[1] 是答案。

题目 7.1(填空题)

为高频重复问题增加缓存。请补充哈希方法(本题指定 md5)、缓存有效期(秒)与写入缓存时使用的键。

import hashlib, time
CACHE = {}
key = hashlib.__【49】__(question.encode("utf-8")).hexdigest()
if key in CACHE and time.time() - CACHE[key][0] < __【50】__: # 有效期
return CACHE[key][1]
...
CACHE[__【51】__] = (time.time(), answer)
点击查看答案

答案:

  • 【49】:md5
  • 【50】:300 (按题面推断)
  • 【51】:key

解析:缓存键要由问题内容决定,md5(…).hexdigest() 把问题压成固定长度键;命中要同时满足键存在且未过 TTL。题面指定 md5,TTL 取常见起点 300 秒;写入必须用同一个 key,否则永远命中不了。

题目 7.2(填空题)

模型服务异常时返回友好降级提示,返回体中用 __【52】__ 字段标记本次为降级回复(本题指定该字段名);同时保持 HTTP 状态码为 __【53】__。

except Exception:
return JSONResponse(
content={"reply": "系统繁忙,请稍后重试,或致电图书馆总服务台6600。",
"__【52】__": True},
status_code=__【53】__)
点击查看答案

答案:

  • 【52】:fallback
  • 【53】:200

解析:降级是业务层已经处理过的异常,不是服务器没处理完;用 200 避免客户端重试和监控误报。题面指定用 fallback 字段标记,所以填 fallback 和 200。

题目 7.3(填空题)

运行性能对比脚本(附件 perf_check.py),结果中衡量优化后平均响应时间的字段是 __【54】__;衡量优化前平均响应时间的字段是 __【55】__。

终端窗口
python perf_check.py --n 15 | tee submission/m2/perf_compare.json
# 输出示例:{"avg_ms_off": 41.2, "__【54】__": 18.7, "n": 15}
点击查看答案

答案:

  • 【54】:avg_ms_on
  • 【55】:avg_ms_off

解析:性能脚本对比 off/on 两组,字段名要能一眼看出组别。题面示例已给 avg_ms_off 是优化前,优化后对应 avg_ms_on,所以两个空是 avg_ms_on 和 avg_ms_off。

题目 7.4(填空题)

perf_check.py 的 on 组对同一问题连续请求 n 次以验证缓存命中:第 1 次未命中,从第 __【56】__ 次起全部命中缓存(填数字)。另:该脚本命令行参数 --n 的默认取值为 __【57】__(填数字,见附件 argparse 配置)。

on = [timed_rag("借阅卡补办需要什么材料?") for _ in range(args.n)]
点击查看答案

答案:

  • 【56】:2
  • 【57】:10 (按题面推断)

解析:第 1 次缓存未命中,写入后第 2 次开始命中,所以从第 2 次起全部命中。—n 的默认值看 argparse 配置,题面示例显式传 15 不代表默认是 15;按常见配置取 10。

题目 7.5(填空题)

降级响应既要有降级标记,又要保持 HTTP 状态码 200,可借助 FastAPI 显式构造响应对象:return __【58】__(content={...}, status_code=200)。请补充类名。

from fastapi.responses import __【58】__
return __【58】__(content={"reply": "...", "fallback": True}, status_code=200)
点击查看答案

答案: JSONResponse

解析:JSONResponse 可以同时指定响应体和状态码;HTTPException 会走 4xx/5xx。题面要求保持 200,所以用 JSONResponse。

题目 7.6(填空题)

为便于核对摘要长度与显示缓存状态,请补充返回体字段名:摘要字符数用 __【59】__ 字段;本次是否命中缓存用 __【60】__ 字段;检索到的相似度列表用 __【61】__ 字段(见应用实现)。

return JSONResponse({"summary": answer, "__【59】__": len(answer)})
...
return JSONResponse({... "__【60】__": True}) # 命中缓存
...
return JSONResponse({... "__【61】__": [round(s, 4) for s in scores]})
点击查看答案

答案:

  • 【59】:char_count (按题面推断)
  • 【60】:cache_hit (按题面推断)
  • 【61】:scores (按题面推断)

解析:返回体字段是接口契约:摘要字符数 char_count、缓存命中 cache_hit、相似度列表 scores。题面按这三个含义留空,填错会导致前端或评测脚本取不到值。

模块三:智能体层:多智能体编排与安全管控

任务 8:运维工单智能体角色设计

基础知识

多智能体就是把工单拆成几步,交给不同角色处理。工单流程里三个角色:

角色职责输入输出工具权限
Planner解析意图、拆步骤工单、历史intent、steps只读、规划类
Executor按计划调工具stepsresults、draft业务工具白名单
QA质检、合规复核、面向用户draft、证据final、问题清单只读、审核类

拆不拆角色,看子任务是否需要独立的推理、上下文、工具权限,或者能不能并行。确定的数据转换用函数或工作流节点就行;多一个 Agent 就多一次模型调用、多一层延迟和错误传播。

角色之间传 JSON,不传自然语言:

{
"ticket_id": "T2001",
"intent": "repair",
"steps": [
{"tool": "shelf_query", "args": {"room": "R305"}}
]
}

steps 是有序数组,tool 是工具名,args 是参数。执行日志至少记 tool、args、result:调了什么、传了什么、返回什么。少了 result,事后分不清是工具失败还是模型编造。

契约要能校验。JSON Schema 约束必填、类型、枚举、范围,additionalProperties: false 拒绝未知字段。解析失败要有兜底,不能让流程崩掉。

设计文档写清楚:

  1. 角色英文名、中文诨名、负责的子任务。
  2. 每个角色的输入、输出字段。
  3. 工具权限、失败处理、终止条件。

比如 Planner 输出 steps,Executor 输入 steps、输出 results,QA 输入 draft、输出 final。交接时不用猜。

执行过程用状态机:planned → executing → reviewing → done/failed,非法迁移直接拒绝。设最大步数、最大重试次数、总 token/时间预算;检测重复动作和无进展,触发重规划或转人工。子 Agent 失联不代表它的外部动作没发生,重试前确认幂等。

常见失败模式:

  • 角色职责重叠,Planner 又规划又执行;
  • 契约没 schema,Executor 收到一段自然语言;
  • QA 只是把生成结果再生成一遍,没有事实核查;
  • Executor 工具权限过大,能删库;
  • 没有终止条件,循环烧钱;
  • 错误逐级传递,最后没人负责。

题干 8.3 的文字和代码表有一处不一致:文字说三行诨名依次是“调度员、__、质检员”,代码表第三行却是 | __ | __ |。按代码表的结构,第三行是 QA,诨名应为质检员;严格按文字顺序读,空会落在第二行 Executor 的“维修工”上。这份答案按代码表作答,并在题目解析里注明这个矛盾。

代码说明

字段类型含义
ticket_idstring工单编号,串起日志和结果
intentstring意图类别,如 repair
stepsarray有序步骤列表
toolstring工具名,必须在注册表里存在
argsobject工具参数
resultobject工具返回,日志里必须记

JSON 例子里的 steps 是数组,执行时按顺序遍历;tool 和 args 是每一步的固定字段。json.loads 解析后就是 Python 的 dict/list,可以直接用 ["steps"]、[0]["tool"] 取值。

题目 8.1(填空题)

工单流程为“读者报障→意图解析与规划→工具执行→质检复核→输出建议”。三个角色分工如下表,请补全角色英文名与诨名;此外设计文档还须为每个角色写明输入与 __【64】__ 两类字段约定,以保证角色间 I/O 契约完整。

| 角色 | 诨名 | 负责子任务 |
|---|---|---|
| __【62】__ | 调度员 | 意图解析与任务规划,输出步骤列表 |
| Executor | __【63】__ | 按计划调用工具并执行步骤 |
| QA | 质检员 | 对建议做质检与合规复核,并面向用户输出 |
点击查看答案

答案:

  • 【62】:Planner
  • 【63】:维修工
  • 【64】:输出

解析:三角色按职责分工:Planner 解析意图、拆步骤;Executor 调工具;QA 做质检和合规复核。题面第一行是“意图解析与任务规划”,对应 Planner;Executor 的诨名是“维修工”;I/O 契约要写输入和输出两类字段。

题目 8.2(填空题)

角色间 I/O 约定采用 JSON 契约。请补充 intent 取值、steps 中表示工具名的字段名,以及执行日志必须记录的第三个字段名。

{"ticket_id": "T2001",
"intent": "__【65】__",
"steps": [{"__【66】__": "shelf_query", "args": {"room": "R305"}}]}
# Executor 每次工具调用须记录三个字段:
# {"tool": "shelf_query", "args": {...}, "__【67】__": {...}}
点击查看答案

答案:

  • 【65】:repair (按题面推断)
  • 【66】:tool
  • 【67】:result

解析:工单意图是报修,取 repair;steps 里表示工具名的字段是 tool;执行日志要能复盘“调了什么、传了什么、返回什么”,第三个字段是 result。题面 JSON 骨架和日志注释分别对应这三个空。

题目 8.3(填空题)

请把三角色设计与输入/输出字段约定写入设计文档 agent_design.md。设计文档须写出三角色的英文名与分工。其中负责“对建议做质检与合规复核,并面向用户输出”的角色英文名是 __【68】__;三行对应的中文诨名依次为“调度员、__【69】__、质检员”。

# 角色设计(节选)
| 角色 | 诨名 |
|---|---|
| Planner | 调度员 |
| Executor | 维修工 |
| __【68】__ | __【69】__ |
点击查看答案

答案:

  • 【68】:QA
  • 【69】:质检员 (按题面推断)

解析:QA 的职责是质检、合规复核并面向用户输出,对应题面第三行;代码表第三行是 QA,所以诨名取“质检员”。题干文字的顺序与代码表冲突,按代码表作答。

任务 9:多工具驱动的智能体流程实现

基础知识

模型只输出工具名和参数,真正的 HTTP 请求由宿主程序执行。模型说“已执行”不算凭证。

工具注册表就是名字到 URL 的映射:

TOOLS = {
"shelf_query": "http://127.0.0.1:9500/tools/shelf_query",
"log_query": "http://127.0.0.1:9500/tools/log_query",
"kb_query": "http://127.0.0.1:9500/tools/kb_query",
"resource_query": "http://127.0.0.1:9500/tools/resource_query",
}
def call_tool(name, args):
return requests.get(TOOLS[name], params=args, timeout=5).json()
环节关键点本题
注册名字、URL、描述、schemaTOOLS 字典
调用GET 查询 / POST 写入requests.get(..., params=...)
解析json.loads 字符串转对象Planner 输出
执行按 steps 顺序收集 resultsExecutor
记录tool、args、resultJSONL 日志

Function Calling 的完整链路:

  1. 应用把工具 schema 发给模型。
  2. 模型决定是否调用,返回工具名和参数。
  3. 应用执行工具,把结果回传给模型。
  4. 模型基于结果继续生成,或再调用下一个工具。

工具 schema 用 JSON Schema:

{
"type": "function",
"function": {
"name": "shelf_query",
"description": "查询书架设备状态;不要用它查日志",
"parameters": {
"type": "object",
"properties": {
"room": {"type": "string", "description": "房间号,如 R305"}
},
"required": ["room"],
"additionalProperties": false
},
"strict": true
}
}
  • description 写清用途、边界和“什么时候不要用”,它比函数名更影响选型。
  • required 标必填;additionalProperties: false 拒绝多余参数;strict: true 要求严格匹配 schema(支持该选项的接口)。
  • 工具职责不要重叠;参数尽量用枚举、绝对路径、明确单位,减少模型犯错。
  • 工具结果回传时,OpenAI 用 role: "tool" + tool_call_id,Anthropic 用 tool_result 块 + tool_use_id;并行调用要按 id 对应。
  • tool_choice 可控制 auto / required / none / 指定工具;不是所有模型都支持。
  • 工具返回同样是不可信输入,可能带提示注入;把它当数据,不要当指令。

查询用 GET,参数放 params;写操作用 POST,JSON 放 json。timeout=5 是硬约束,工具卡住时不能让智能体无限等。读操作可以重试;写操作先确认幂等,否则会重复下单、重复扣款。

Planner 的系统消息里写清 schema,用户消息放工单内容:

messages = [
{"role": "system", "content": PLANNER_SYSTEM},
{"role": "user", "content": ticket},
]
plan = llm_generate(messages)
if isinstance(plan, str):
steps = json.loads(plan)["steps"]
else:
steps = plan["steps"]

模型经常在 JSON 外面裹代码围栏或解释文字,解析前剥离;解析失败要有兜底计划。别假设模型一定按 schema 输出。

执行阶段按 steps 顺序调工具。有依赖的步骤串行;无依赖的读操作可以并行,写操作串行或加锁。某一步失败时,决定跳过、重试、终止还是转人工,并把失败原因写进结果,不要静默返回空。

最后 qa_check(draft) 做质检:查事实、权限、合规和格式。质检不是再生成一遍,要能拿到工具返回和检索片段作为证据。

日志用 JSONL,一行一个对象,追加写:

path = "submission/m3/agent_run_log.jsonl"
with open(path, "a", encoding="utf-8") as f:
line = json.dumps({"tool": name, "args": args, "result": result},
ensure_ascii=False)
f.write(line + "\n")

json.loads 是字符串转对象,json.dumps 是对象转字符串;open(..., "w") 会清空旧日志,审计场景必须用 a。每条日志至少带时间、工具名、参数、结果、耗时和 trace_id,敏感参数脱敏。

工具顺序有语义。空调告警工单先 shelf_query 确认设备状态,再 log_query 查服务日志;先证据、后诊断。反过来先查日志,可能把历史故障当成当前状态。

几个坑:

  • 没有超时;
  • 重试非幂等写操作;
  • 把工具返回当可信指令(提示注入);
  • 日志里写密钥;
  • json.loads / json.dumps 混用;
  • 工具描述太模糊导致选错;
  • 没有最大轮次导致死循环。

代码说明

代码作用
TOOLS = {...}名字到 URL 的注册表
TOOLS[name]按名字取 URL,名字不存在会抛 KeyError
requests.get(url, params=args, timeout=5)GET 请求,params 自动拼查询串
.json()响应体转 dict
json.loads(plan)JSON 字符串转 dict/list
isinstance(plan, str)判断模型返回的是字符串还是已经解析好的对象
json.dumps(obj, ensure_ascii=False)对象转 JSON 字符串,保留中文
open(path, "a", encoding="utf-8")追加模式打开,不清空旧日志
f.write(line + "\n")JSONL 一行一条,换行分隔

JSONL 长这样:

{"tool": "shelf_query", "args": {"room": "R305"}, "result": {"status": "空调停机"}}
{"tool": "log_query", "args": {"service": "rfid"}, "result": {"log": "..."}}

每行是独立 JSON,不是数组;可以逐行追加、逐行读取。json.loads 一次解析一行,json.dumps 一次生成一行。

题目 9.1(填空题)

在智能体中注册工具并封装调用。请补充工具名(URL 见附件 kb_tools_server.py)、HTTP 方法与超时秒数(本题指定 5 秒)。

TOOLS = {
"shelf_query": "http://127.0.0.1:9500/tools/shelf_query",
"__【70】__": "http://127.0.0.1:9500/tools/log_query",
"kb_query": "http://127.0.0.1:9500/tools/kb_query",
"resource_query": "http://127.0.0.1:9500/tools/resource_query"}
def call_tool(name, args):
return requests.__【71】__(TOOLS[name], params=args, timeout=__【72】__).json()
点击查看答案

答案:

  • 【70】:log_query
  • 【71】:get
  • 【72】:5

解析:工具注册表用名字映射 URL;日志查询工具名是 log_query;查询类接口用 GET,参数放 params;题面指定超时 5 秒,所以填 log_query、get、5。

题目 9.2(填空题)

实现“报障→规划→工具执行→质检→建议”完整流程。请补充:Planner 约束模型只输出 JSON 所用的角色名、解析 JSON 字符串的方法名、质检复核函数名(本题指定为 qa_check)。

messages = [{"role": "__【73】__", "content": PLANNER_SYSTEM},
{"role": "user", "content": ticket}]
plan = llm_generate(messages)
steps = json.__【74】__(plan)["steps"] if isinstance(plan, str) else plan["steps"]
results = [call_tool(s["tool"], s["args"]) for s in steps]
draft = executor_summarize(steps, results)
final = __【75】__(draft)
点击查看答案

答案:

  • 【73】:system
  • 【74】:loads
  • 【75】:qa_check

解析:Planner 的“只输出 JSON”是系统级约束,放 system;模型返回字符串时用 json.loads 转对象;质检函数按题面指定为 qa_check。所以三个空是 system、loads、qa_check。

题目 9.3(填空题)

空调告警工单的规划顺序为“先确认设备状态、后查询服务日志”。第二步查询日志的工具为 log_query(URL 见题目 9.1),则第一步确认设备状态的工具是 __【76】__(工具名)。

# 规划顺序:先确认,后查日志
steps = [{"tool": "__【76】__", "args": {"room": "R305"}},
{"tool": "log_query", "args": {"service": "rfid"}}]
点击查看答案

答案: shelf_query

解析:题面要求先确认设备状态、后查服务日志。设备状态对应 shelf_query,日志对应 log_query;顺序反了会把历史日志当成当前状态,所以第一空填 shelf_query。

题目 9.4(填空题)

运行日志采用追加写、逐行记录。请补充打开文件时的模式参数(单字符),以及把字典序列化为 JSON 字符串的方法名(注意与题目 9.2 的解析方法区分)。

f = open("submission/m3/agent_run_log.jsonl", "__【77】__", encoding="utf-8")
line = json.__【78】__({"tool": name, "args": args, "result": result},
ensure_ascii=False) + "\n"
f.write(line)
点击查看答案

答案:

  • 【77】:a
  • 【78】:dumps

解析:题面要求日志追加写、逐行记录。JSONL 一行一个 JSON,所以模式是 a;对象转 JSON 字符串用 json.dumps,ensure_ascii=False 保留中文。解析用 json.loads,不要混。

题目 9.5(填空题)

请阅读附件 kb_tools_server.py 源码回答问题(答案以附件为准)。

# 附件源码节选
def log_query(service: str = Query("__【79】__")): # 1) 默认服务代号
return {"service": service, "log": "..."}
def shelf_query(room: str = Query("R305")):
return {"room": room, "status": "空调停机", "level": __【80】__} # 2) 告警级别
def kb_query(q: str = Query("借阅卡补办")):
return {"q": q, "result": "命中:...维修/制卡人员2个工作日内__【81】__处理。"} # 3) 处置词
@app.get("/tools/__【82】__") # 4) 第 4 个工具(电子资源并发状态查询)
def __【82】__(db: str = Query("cnki")):
return {"db": db, "concurrent": 128, "limit": 120, "status": "已满"}
点击查看答案

答案:

  • 【79】:rfid (按题面推断)
  • 【80】:2 (按题面推断)
  • 【81】:完成 (按题面推断)
  • 【82】:resource_query

解析:第 4 个工具是电子资源并发状态查询,路由和函数名都是 resource_query;默认服务代号取 9.3 示例的 rfid;停机告警按三级制取 2;处置词取“完成”。79–81 依赖附件,实际以 kb_tools_server.py 为准。

任务 10:跨场景编排与安全管控

基础知识

路由决定“这句话该谁答”。关键词规则最简单:

REPAIR_KEYWORDS = ["报修", "故障", "空调", "门禁", "网络"]
def route(question: str) -> str:
if any(k in question for k in REPAIR_KEYWORDS):
return "repair_agent"
return "visitor_agent"
方式优点缺点适用
关键词/规则快、便宜、可解释同义词、错别字会漏领域词稳定
语义相似能处理同义表达需要标注和阈值意图边界清晰
模型分类能利用上下文多一次调用,可能不稳定复杂意图
混合路由规则兜底 + 模型补充逻辑复杂生产常用

统一入口的返回体要带 agent,值是 repair_agent 或 visitor_agent。没有这个字段,前端分不清场景,日志也没法按智能体统计。再带上 trace_id、耗时、是否降级和引用证据。

敏感指令在模型和工具之前拦:

SENSITIVE = ["删除数据库", "关机", "重启服务器", "清除日志"]
if any(s in question for s in SENSITIVE):
return {"reply": "该操作涉及敏感指令,已拒绝执行。", "blocked": True}

这是输入侧护栏。黑名单只能挡已知表达,换同义词、拼音、拆字、编码就可能绕过;要配合最小权限、工具白名单、参数校验、人工确认和审计。输入先做规范化:全角转半角、去零宽字符、统一大小写、还原常见编码。

权限校验是授权,不是认证。认证回答“你是谁”,授权回答“你能做什么”。

role = request.headers.get("X-User-Role", "user")
if action in ["restart", "close_ticket"] and role != "admin":
raise HTTPException(status_code=403, detail="权限不足")
状态码含义场景
401未认证没带 token、token 过期
403已认证但无权限普通用户执行管理员操作
404资源不存在工单 ID 错误
429限流请求过多
500服务端错误未捕获异常
503服务不可用维护、熔断

生产环境不要直接信任客户端传来的 X-User-Role,由网关注入或签名校验。角色校验放在真正的执行入口,不要只在提示词里写“你是管理员”。

审计用 JSONL:

record = {
"timestamp": now_iso(),
"user": user,
"action": action,
"result": result,
}
append_jsonl(AUDIT_LOG_PATH, record)

追加写,只追加不修改;拦截事件也要记,否则只能查到成功操作。日志防篡改、设保留期,不要把密码和 token 写进去。完整一点还要带 trace_id、资源 ID、参数摘要、模型版本、工具版本。

安全做纵深防御:输入过滤 → 意图/权限校验 → 工具白名单 → 沙箱/最小权限 → 输出过滤 → 审计监控。任何一层都不是绝对安全;系统提示不是安全边界,模型可以被诱导,真正的边界在代码和权限系统里。

容易出错的地方:直接信任 X-User-Role;只拦“删除数据库”不拦同义表达;只记录成功操作;审计日志普通用户能删;工具权限过大;没有限流;把模型输出直接当命令执行。

代码说明

代码作用
any(k in question for k in REPAIR_KEYWORDS)任一关键词出现在问题里就返回 True
question用户输入字符串
request.headers.get("X-User-Role", "user")取请求头,缺失时默认 user
raise HTTPException(status_code=403, detail=...)抛 403,FastAPI 转成 JSON 错误响应
now_iso()当前时间的 ISO 字符串
append_jsonl(path, record)把一条记录追加到 JSONL

any(...) 里是生成器表达式,命中第一个关键词就短路,不会继续遍历。headers.get 的第二个参数是默认值,没传角色头时按普通用户处理。

题目 10.1(填空题)

基于关键词的规则路由:命中运维/设施关键词进“设备报修智能体”(本题指定标识符 repair_agent),否则进“访客咨询智能体”(本题指定标识符 visitor_agent)。请补充标识符、路由方式(四个中文,见题面括号提示)与统一入口用于标明实际应答者的字段名(本题指定)。

REPAIR_KEYWORDS = ["报修", "故障", "空调", "门禁", "网络"]
def route(question: str) -> str:
if any(k in question for k in REPAIR_KEYWORDS):
return "__【83】__"
return "__【84】__"
# 该路由方式属于 __【85】__ 路由
# 统一入口返回结果中用 __【86】__ 字段标明实际应答的智能体
点击查看答案

答案:

  • 【83】:repair_agent
  • 【84】:visitor_agent
  • 【85】:基于规则 (按题面推断)
  • 【86】:agent (按题面推断)

解析:关键词路由命中“报修/故障/空调/门禁/网络”就交给设备报修智能体,否则交给访客咨询智能体。题面指定标识符 repair_agent / visitor_agent,所以前两空是它们;这是基于规则的路由;返回体用 agent 标明实际应答者。

题目 10.2(填空题)

敏感指令拦截。请补充拦截文案、标记字段的值,以及该机制所属的安全防护层级(两字中文)。

SENSITIVE = ["删除数据库", "关机", "重启服务器", "清除日志"]
if any(s in question for s in SENSITIVE):
return {"reply": "__【87】__", "blocked": __【88】__}
# 该机制属于 __【89】__ 安全防护(拦截事件须写入审计日志)
点击查看答案

答案:

  • 【87】:该操作涉及敏感指令,已拒绝执行。 (按题面推断)
  • 【88】:True
  • 【89】:输入 (按题面推断)

解析:敏感指令拦截是输入侧护栏,命中高风险动作就拒绝;题面要求固定文案并标记 blocked,所以文案可用“该操作涉及敏感指令,已拒绝执行。”,blocked=True;机制属于输入安全,拦截事件要写审计日志。

题目 10.3(填空题)

权限校验:重启设备、关闭工单为高权限操作,仅管理员可执行。请补充角色名、无权限时的状态码,以及该校验所属的控制类型(两字中文)。

role = request.headers.get("X-User-Role", "user")
if action in ["restart", "close_ticket"] and role != "__【90】__":
raise HTTPException(status_code=__【91】__, detail="权限不足")
# 该校验属于 __【92】__ 控制
点击查看答案

答案:

  • 【90】:admin
  • 【91】:403
  • 【92】:权限

解析:认证回答“你是谁”,授权回答“你能做什么”。重启设备、关闭工单是高权限动作,题面要求仅管理员可执行,所以角色是 admin;身份已知但权限不足返回 403;这是权限控制。

题目 10.4(填空题)

操作审计采用 JSONL 格式,每条记录含四个字段(本题指定字段集为 timestamp、user、action、result)。其中记录“操作发生时间”的字段名是 __【93】__;记录“操作者身份”的字段名是 __【94】__。

record = {"__【93】__": now_iso(), "__【94】__": user, "action": action, "result": result}
append_jsonl(AUDIT_LOG_PATH, record)
点击查看答案

答案:

  • 【93】:timestamp
  • 【94】:user

解析:审计记录要能回答“谁、何时、做了什么、结果如何”。题面指定字段集 timestamp、user、action、result,所以操作时间是 timestamp,操作者身份是 user。

考前检查清单

  • 代码填空只写空缺本身,不带等号、引号、括号或分号。
  • json.loads 解析字符串,json.dumps 序列化对象;JSONL 用追加模式 a。
  • 查询类工具用 requests.get(..., params=...);对话、摘要类接口用 requests.post(..., json=...)。
  • max_new_tokens 数的是 token,不是汉字;字符数用 len()。
  • 401 是未认证,403 是已认证但无权限;高权限动作只给 admin。
  • FAISS 用内积做余弦相似度时,文档向量和查询向量都要 L2 归一化。
  • 缓存要设 TTL;降级响应保持 HTTP 200,并用 fallback: true 标记。
  • 审计日志只追加不修改,拦截事件也要记录。