mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
d0082b4645
为什么:这个页签一次打开,/api/evals/signals 按窗口调 compute_stats(缺省 6 个窗口,每个都聚一次问题),页面另调 /api/ledger/stats,一共聚 8 遍;chat/questions.cluster_questions 每遍都把窗口里的问题原文整批发给 embed_texts, 一次里重复的问法也照发。配了远程向量模型时每打开一次就是几百到几千次向量调用,慢、花钱。复现:本地 3 句问题各问 3 次, 一次打开每句被取了 9 次向量。 做法(改在全站唯一的取问题、聚类处 app/chat/questions.py,不动 ledger/stats 的统计口径): - 问题原文 → 向量在进程里留一份(_vectors,最多 VECTOR_CACHE_MAX=1000 句、最近用到的留着),同一句话只取一次, 一次里重复的问法去重后只发一句;/signals、/ledger/stats、认证队列、治理页的探索问题聚类都走它; - 缓存认「哪一个 embedder、哪一个取向量的函数」算的:设置页换了向量模型(configure 换了对象)整份丢掉,不把两个 向量空间的坐标混着比;取的这一会儿换了的,这一批照用、不留; - 取向量按事件循环串行(每个循环一把锁,同项目里别处的写法):页面并行取的两个接口不把同一批问题各发一遍。 测试:test_evals_ui_api 新增「一次打开每句问题只取一次向量、再打开一次都不取、两个接口并行也一样」; 新文件 tests/test_question_vectors.py(去重、换模型丢缓存、取到一半换模型、上限与最近使用、并行只发一批)。 去掉缓存回跑:每句 9 次;去掉换模型的核对:2 条失败;去掉共用的锁:并行那条失败。
131 lines
5.7 KiB
Python
131 lines
5.7 KiB
Python
"""问题聚类的向量只取一次(app/chat/questions.py 模块说明「向量只取一次」)。
|
|
|
|
评测页「口径命中与重复度」一次打开要按窗口聚好几遍问题,页面另调账本统计;配了远程向量模型时每遍都把几百句问题重发一次。
|
|
这里守进程里那一份问题原文 → 向量:
|
|
|
|
- 同一句话只取一次,一次里重复的问法也只发一句;顺序、对应关系照旧;
|
|
- 换了向量模型(或取向量的函数)就整份丢掉,不把两个向量空间的坐标混着比;取的这一会儿换了的,这一批照用、不留;
|
|
- 最多留 VECTOR_CACHE_MAX 句,最近用到的留着;一次超过上限也照样每句拿到自己的向量。
|
|
|
|
问题都是虚构的。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.chat import questions as qmod
|
|
from app.chat.questions import Question, cluster_questions
|
|
from app.memory import embeddings
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_cache():
|
|
qmod._VECTORS.clear()
|
|
qmod._VECTORS_OF = None
|
|
yield
|
|
qmod._VECTORS.clear()
|
|
qmod._VECTORS_OF = None
|
|
|
|
|
|
DIM = 32
|
|
|
|
|
|
def _fake(calls: list[list[str]], *, scale: float = 1.0, made: dict[str, list[float]] | None = None):
|
|
"""每句话一个确定的向量:「同类」开头的几句彼此相同(聚得到一起),别的各占一个方向(互不相像)。made 记下给过的向量。"""
|
|
seen: dict[str, int] = {}
|
|
|
|
async def embed(texts):
|
|
calls.append(list(texts))
|
|
out = []
|
|
for t in texts:
|
|
vec = [0.0] * DIM
|
|
vec[0 if t.startswith("同类") else 1 + seen.setdefault(t, len(seen))] = scale
|
|
out.append(vec)
|
|
if made is not None:
|
|
made[t] = vec
|
|
return out
|
|
|
|
return embed
|
|
|
|
|
|
def _qs(*texts: str) -> list[Question]:
|
|
return [Question(text=t, at=None) for t in texts]
|
|
|
|
|
|
async def test_each_text_is_embedded_once_and_duplicates_in_one_call_are_sent_once(monkeypatch):
|
|
calls: list[list[str]] = []
|
|
monkeypatch.setattr(embeddings, "embed_texts", _fake(calls))
|
|
got = await cluster_questions(_qs("同类:上周销售额", "同类:上周销售额", "别的:库存", "同类:上周销售额"))
|
|
assert calls == [["同类:上周销售额", "别的:库存"]], "一次里重复的问法只发一句"
|
|
assert [c.count for c in got] == [3, 1]
|
|
again = await cluster_questions(_qs("别的:库存", "同类:上周销售额", "新的:退款率"))
|
|
assert calls[1:] == [["新的:退款率"]], "已经取过的不再取"
|
|
assert [c.count for c in again] == [1, 1, 1]
|
|
|
|
|
|
async def test_a_new_embedder_drops_every_vector(monkeypatch):
|
|
first: list[list[str]] = []
|
|
second: list[list[str]] = []
|
|
monkeypatch.setattr(embeddings, "embed_texts", _fake(first))
|
|
await cluster_questions(_qs("同类:本月退款率", "别的:毛利"))
|
|
monkeypatch.setattr(embeddings, "embed_texts", _fake(second, scale=2.0))
|
|
await cluster_questions(_qs("同类:本月退款率", "别的:毛利"))
|
|
assert second == [["同类:本月退款率", "别的:毛利"]], "换了取向量的函数:整份重取"
|
|
assert qmod._VECTORS["同类:本月退款率"][0] == 2.0
|
|
|
|
# 设置页换了向量模型(configure 换了 embedder 对象)同理
|
|
other = embeddings.LocalEmbedder()
|
|
monkeypatch.setattr(embeddings, "get_embedder", lambda: other)
|
|
await cluster_questions(_qs("同类:本月退款率"))
|
|
assert second[1:] == [["同类:本月退款率"]]
|
|
|
|
|
|
async def test_switching_embedder_mid_call_keeps_the_batch_but_not_the_cache(monkeypatch):
|
|
calls: list[list[str]] = []
|
|
later: list[list[str]] = []
|
|
|
|
async def switching(texts):
|
|
calls.append(list(texts))
|
|
# 取的这一会儿设置页换了向量模型
|
|
monkeypatch.setattr(embeddings, "embed_texts", _fake(later))
|
|
return [[1.0] + [0.0] * (DIM - 1) for _ in texts]
|
|
|
|
monkeypatch.setattr(embeddings, "embed_texts", switching)
|
|
got = await cluster_questions(_qs("同类:华东门店", "同类:华北门店"))
|
|
assert [c.count for c in got] == [2], "这一批照用旧向量算完"
|
|
assert not qmod._VECTORS, "旧向量不留给下一次"
|
|
await cluster_questions(_qs("同类:华东门店"))
|
|
assert later == [["同类:华东门店"]]
|
|
|
|
|
|
async def test_the_cache_is_bounded_and_keeps_the_recently_used(monkeypatch):
|
|
calls: list[list[str]] = []
|
|
made: dict[str, list[float]] = {}
|
|
monkeypatch.setattr(embeddings, "embed_texts", _fake(calls, made=made))
|
|
monkeypatch.setattr(qmod, "VECTOR_CACHE_MAX", 3)
|
|
texts = [f"别的:第{i}问" for i in range(5)]
|
|
got = await qmod._vectors(texts)
|
|
assert got == [made[t] for t in texts] and len({tuple(v) for v in got}) == 5, "一次超过上限也每句拿到自己的向量"
|
|
assert list(qmod._VECTORS) == texts[2:], "只留最近的 3 句"
|
|
await qmod._vectors([texts[2]])
|
|
await qmod._vectors(["别的:又一问"])
|
|
assert list(qmod._VECTORS) == [texts[4], texts[2], "别的:又一问"], "最近用到的留着,最久没用的先丢"
|
|
|
|
|
|
async def test_two_callers_at_once_send_the_batch_once(monkeypatch):
|
|
"""页面并行取 /api/evals/signals 和 /api/ledger/stats:同一批问题不各发一遍。"""
|
|
import asyncio
|
|
|
|
calls: list[list[str]] = []
|
|
inner = _fake(calls)
|
|
|
|
async def slow(texts):
|
|
await asyncio.sleep(0.02)
|
|
return await inner(texts)
|
|
|
|
monkeypatch.setattr(embeddings, "embed_texts", slow)
|
|
batch = _qs("同类:上周客单价", "别的:会员数")
|
|
a, b = await asyncio.gather(cluster_questions(batch), cluster_questions(batch))
|
|
assert calls == [["同类:上周客单价", "别的:会员数"]]
|
|
assert [c.count for c in a] == [c.count for c in b] == [1, 1]
|