本地 AI 工作台的 Agent 化改造:从聊天转发到 22 个工具

项目地址:https://github.com/yese6g/S_Wolf

这个项目最初的目标很小:做一个完全本地运行、能接自己的 API Key、能切换本地 Ollama 的AI 助手,前端不依赖任何 CDN,后端不依赖任何外部服务。做完第一版之后陆续加上了工具调用、知识库、长期记忆、文件沙箱、任务规划和权限审批,现在是一个带 22 个工具的 Agent 工作台。这篇文章记录实现层面的决策与细节:分层方式、Agent 循环的每一处边界条件、上下文与 token成本的计算方法、搜索结果的过滤策略、RAG 的离线 embedding 兜底、权限沙箱的实现,以及测试方案。所有代码片段都来自仓库当前版本,数据来自实际测量。

一、技术选型与项目规模

部分 选型 说明
后端 FastAPI + uvicorn main.py 挂载路由与静态资源,SilverWolf.py 是唯一的路由层
会话存储 SQLite 单文件 chat_history.db,会话/消息/附件三张表
向量库 ChromaDB(PersistentClient) 本地持久化,cosine 距离空间
Embedding 本地模型优先,内置 hash 兜底 无模型文件时走字符 n-gram 特征,保证零下载可用
模型接入 OpenAI SDK 兼容 Ollama 与任意 OpenAI 兼容接口,Provider 化配置
前端 Vue 3 本地运行时 vue.global.prod.js 进仓库,无构建步骤、零 CDN

当前规模:Python 约 6800 行(含测试),前端 JavaScript 约 3500 行(不含 Vue 运行时),22 个内置工具,12 个 pytest 用例,changecode.md 里 28 篇已修复问题的复盘记录与 5 条待修项。

二、分层与目录结构

复制代码
frontend/               Vue 3 本地运行时(无构建、零 CDN)
SilverWolf.py           路由层:聊天 / 会话 / 流式 / 提供商 / 设置
agent/runner.py         Agent 多步循环(模型 → 工具 → 模型)
agent/tools/            工具仓库,一个功能一个文件,放进目录自动注册
middleware/context.py   上下文裁剪、压缩、截断
middleware/token_usage.py  Token 统计与估算
middleware/stream_utils.py 流式增量归一化
file_history_store.py   SQLite 会话与消息
rag.py / vector_stores.py / knowledge_base.py  RAG 链路
provider_config.py      提供商与 API Key 管理
file_attachments.py     附件解析

依赖方向固定为 SilverWolf.py → agent/ → middleware/,下层不允许反向 import。这条规则的由来:早期 middleware 里的模块为了拿一个配置项反过来 import 路由层,结果一次改动会波及整条链路,也导致单元测试没法单独跑。

新代码放哪的判断标准:

特征 归属
只有 Agent 模式会用到 agent/
模型可以主动调用 agent/tools/
普通对话与 Agent 对话都要过一遍 middleware/
业务主体(数据库、RAG、附件、配置) 根目录对应模块

前端为什么退掉了构建步骤

第一版是标准的 npm 工程,后来改成 .js 文件直接加载。取舍如下:

npm 工程 本地运行时(当前)
用户前置依赖 Node + npm install
模板写法 .vue 单文件组件 模板字符串
类型检查 / 热更新
静态检查手段 vue-tsc / eslint node --check + 浏览器模板编译验证

这个项目的目标用户是在自己电脑上跑助手的人,减少一个前置依赖的收益大于构建体验的损失。代价用两条纪律补:改前端必须过 node --check,以及必须用真实浏览器实际点击验证;静态资源 URL 全部带 ?v=版本号,避免浏览器拿着旧缓存跑新后端。

三、Agent 主循环

循环的基本形态是标准的 ReAct:调用带工具定义的模型 → 解析工具调用 → 执行工具 →把结果作为 role=tool 消息喂回上下文 → 继续下一轮,直到模型不再请求工具或达到轮数上限。真正需要处理的是边界条件。下面是循环骨架(agent/runner.py):

python 复制代码
run_ctx = run_state.new_run()          # 本轮工具状态:搜索/抓页计数与缓存
token_kwargs = {"max_tokens": reply_max_tokens} if reply_max_tokens else {}
answered = False
steps_total = max(1, int(max_steps or DEFAULT_MAX_STEPS))

for step in range(steps_total):
    run_state.bind(run_ctx)            # 每个 step 重新绑定(原因见第六节)
    tracker.next_step()
    content_parts, reasoning_parts, tool_buffer = [], [], {}

    stream = client.chat.completions.create(
        model=model, messages=messages, tools=tools_schema or None,
        stream=True, stream_options={"include_usage": True}, **token_kwargs,
    )

    for chunk in stream:
        content, reasoning = extract_stream_delta(chunk)
        if reasoning:
            yield {"type": "reasoning", "text": reasoning}
        if content:
            content_parts.append(content)
            yield {"type": "content", "text": content}
        choices = getattr(chunk, "choices", None)
        if choices:
            delta = getattr(choices[0], "delta", None)
            calls = getattr(delta, "tool_calls", None) if delta is not None else None
            if calls:
                _merge_tool_call_fragments(tool_buffer, calls)
        if getattr(chunk, "usage", None):
            usage_obj = chunk.usage

    text = "".join(content_parts)
    reasoning_text = "".join(reasoning_parts)
    if not tracker.add_provider_usage(usage_obj):
        tracker.add_estimate(prompt_text, text + reasoning_text)   # 服务商不给 usage 时退回估算

    if not tool_buffer:                               # 没有工具调用 → 这就是最终回答
        answered = True
        break
    ...

工具调用是分片到达的

流式模式下,函数的 namearguments 会分成多个 chunk 到达,例如 arguments 可能是 '{"ci''ty":"北''京"}'。必须按 index 累积,等这一轮结束后再整体解析:

python 复制代码
def _merge_tool_call_fragments(buffer: Dict[int, Dict[str, Any]], deltas: List[Any]) -> None:
    for d in deltas or []:
        idx = getattr(d, "index", 0) or 0
        slot = buffer.setdefault(idx, {"id": "", "name": "", "arguments": ""})
        if getattr(d, "id", None):
            slot["id"] = d.id
        fn = getattr(d, "function", None)
        if fn is not None:
            if getattr(fn, "name", None):
                slot["name"] = fn.name
            if getattr(fn, "arguments", None):
                slot["arguments"] += fn.arguments        # 字符串拼接,不是覆盖

一次响应可能包含多个并行工具调用(index 不同),所以按 index 建槽位,执行时按 index 排序保证顺序稳定。

两种失败路径

服务商不支持工具或流式用量 。有些 OpenAI 兼容服务商不接受 tools 参数,或者不接受 stream_options。处理方式是逐级降级:先去 stream_options,再去 tools,最后按普通对话走:

python 复制代码
except (BadRequestError, APIError) as e:
    msg = str(e).lower()
    if "max_tokens" in msg or "max output" in msg or ("maximum" in msg and "token" in msg):
        stream = client.chat.completions.create(
            model=model, messages=messages, tools=tools_schema or None,
            stream=True, stream_options={"include_usage": True})      # 去掉 max_tokens
    elif "tool" in msg or "function" in msg:
        tools_unsupported = True
        yield {"type": "warning", "text": "当前模型不支持工具调用,本次以普通对话模式回答"}
        stream = client.chat.completions.create(model=model, messages=messages, stream=True)
    else:
        stream = client.chat.completions.create(
            model=model, messages=messages, tools=tools_schema or None,
            stream=True, **token_kwargs)

轮数或预算用尽但模型还想调工具。这时必须补一次"不带工具的收尾调用",否则用户拿到的最后一句往往只是模型的下一步计划:

python 复制代码
if not answered:
    yield {"type": "warning", "text": "工具轮数用尽,先把已拿到的资料整理成结论"}
    tracker.next_step()
    messages.append({
        "role": "system",
        "content": ("(系统)工具调用次数已用尽,现在禁止再调用任何工具。"
                    "请直接回答用户最初的问题:给出明确结论与关键数据,说明信息来自哪几个来源;"
                    "确实没查到的一部分就直说没查到。"),
    })
    final_stream = client.chat.completions.create(
        model=model, messages=messages, stream=True,
        stream_options={"include_usage": True}, **token_kwargs)
    for chunk in final_stream:
        content, _ = extract_stream_delta(chunk)
        if content:
            yield {"type": "content", "text": content}

单轮还有一个累计 token 硬闸:DEFAULT_MAX_TOTAL_TOKENS = 200_000

每轮结束后检查 tracker.summary()["total_tokens"],超了就停止工具调用、走上面的收尾路径。设这一道的原因是 max_steps 只能限制轮数,限制不了"一步里发五个并行工具调用"带来的上下文膨胀。

四、工具系统

早期实现把所有工具放在一个 460 行的文件里:函数实现、JSON Schema、注册表、执行分发。加一个工具要改三处,其中"忘记登记进注册表"这一项不会报错,只会让模型看不到这个工具。现在改成一个功能一个文件,注册表扫描目录自动发现:

python 复制代码
BUILTIN_TOOLS: Dict[str, Dict[str, Any]] = {}

def _discover() -> None:
    package_dir = Path(__file__).resolve().parent
    for mod_info in sorted(pkgutil.iter_modules([str(package_dir)]), key=lambda m: m.name):
        if mod_info.name.startswith("_"):        # 下划线开头 = 内部零件,不注册
            continue
        try:
            module = importlib.import_module(f"{__name__}.{mod_info.name}")
        except Exception as e:                   # 单个模块坏掉不拖垮整个 Agent
            logger.exception("工具模块 %s 导入失败: %s", mod_info.name, e)
            continue
        for name, spec in (getattr(module, "TOOLS", {}) or {}).items():
            if "handler" not in spec:
                logger.warning("工具 %s 的定义不完整(缺少 handler),已跳过", name)
                continue
            BUILTIN_TOOLS[name] = spec
    logger.info("已注册内置工具 %d 个:%s", len(BUILTIN_TOOLS), "、".join(BUILTIN_TOOLS))

每个工具文件只需要导出 TOOLS

python 复制代码
# agent/tools/datetime_tool.py
def _tool_get_current_time(args: Dict[str, Any]):
    """工具入口:无参数,返回 (给模型看的文本, 前端展示的 meta)。"""
    info = get_current_time()
    return info["text"], {"ok": True, "local_time": info["local_time"],
                          "weekday": info["weekday"], "timestamp": info["timestamp"]}

TOOLS = {
    "get_current_time": {
        "description": "获取当前的日期与时间(含星期与时区)......",
        "parameters": {"type": "object", "properties": {}},
        "handler": _tool_get_current_time,
    },
}

执行侧统一做三件事:查表、兜异常、识别权限标记。

python 复制代码
def execute_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
    args = args or {}
    spec = BUILTIN_TOOLS.get(name)
    if spec:
        try:
            content, meta = spec["handler"](args)
            return {"ok": bool(meta.get("ok", True)), "content": content, "meta": meta}
        except Exception as e:
            logger.exception("内置工具执行失败: %s", name)
            return {"ok": False, "content": f"工具 {name} 执行异常:{str(e)[:200]}",
                    "meta": {"ok": False, "tool": name}}
    custom = next((t for t in _load_custom_tools() if t.get("name") == name), None)
    if custom:
        content, meta = _run_custom_http_tool(custom, args)
        return {"ok": bool(meta.get("ok", True)), "content": content, "meta": meta}
    return {"ok": False, "content": f"没有名为 {name} 的工具。", "meta": {"ok": False, "tool": name}}

工具抛异常会被兜住并转成一条可读的工具结果,循环继续,模型能看到"这个工具失败了"并换别的方式。权限标记写在定义里,由循环统一检查:

python 复制代码
"fs_write": {
    "description": "在工作区里写入或追加一个文本文件......",
    "approval_required": True,      # 写盘属于高危操作,执行前需要用户允许
    "handler": _tool_fs_write,
}

当前 22 个工具按功能分布:

分类 工具
联网 web_searchfetch_page
知识库 rag_searchrag_list_filesrag_write_filerag_delete_filerag_list_knowledge_basesrag_import_url
长期记忆 rememberrecallforget
工作区 fs_listfs_readfs_writefs_delete
任务规划 update_planshow_plan
自我维护 tools_listtools_upserttools_delete
基础能力 get_current_timecalculate

自定义工具走另一套:由 Agent 自己通过 tools_upsert 写成声明式 HTTP 工具,存 data/tools.json,执行时只做"参数替换进 URL 模板 + 发请求",不执行任意代码。

五、流式协议与"过程话"问题

POST /api/chat/stream 用 SSE 推送,每行格式是 data: {json}\n\n。事件类型:

事件 载荷 用途
content {content, session_id} 正文增量
reasoning {reasoning} 模型思考过程(推理模型)
tool_call {tool_call:{name,args}} 开始调用工具
tool_result {tool_result:{name,ok,summary}} 工具执行结果
plan {plan:[{id,text,status}]} 任务清单更新
tool_approval {tool_approval:{request_id,name,args}} 高危操作等待用户允许
demote {demote:"<文本>"} 把已经流出的过程话撤回
usage {usage:{prompt_tokens,...}} 本轮 Token 用量
结尾 {session_id,rag_used,provider_id,model,...} + [DONE] 元信息与结束标志

demote:为什么要"先发再撤回"

工具调用型模型在调用工具之前,通常先输出一句打算,例如:

复制代码
搜索结果全是导航站,没用。换几个精确的关键词。

这句话来自 content 字段,而循环把每一步的 content 都累积成了最终回答。当轮数用尽时流程直接结束,这句话就成了用户看到的"回答"。实测那次故障的完整回答是 71 个字符:

复制代码
搜索结果全是导航站,没用。换几个精确的关键词。Bing爬虫给的都是导航/百科垃圾。
换Tavily试试。Tavily 有料了。挖几个页面看细节。

那一轮实际消耗 17232 Token(4 步),没有任何结论。修法分两部分:

  1. 带工具调用的那一步,把它输出的正文标记为"过程",从回答里撤回;
  2. 轮数/预算用尽时,强制补一次不带工具的收尾调用(见第三节)。

撤回用事件而不是"先缓存不发"的原因:如果为了判断这句话是不是最终回答而缓存整段文本,用户会先看到十几秒空白,流式的打字效果就没了。所以照常流出,事后发一个 demote

python 复制代码
# agent/runner.py
if text.strip():
    yield {"type": "demote", "text": text}
javascript 复制代码
// frontend/src/stores.js ------ 收到 demote:从回答里摘出来,挪进思考过程
if (parsed.demote) {
    const dropped = parsed.demote;
    if (fullText.endsWith(dropped)) {
        fullText = fullText.slice(0, fullText.length - dropped.length);
    } else {
        fullText = fullText.split(dropped).join('');
    }
    botMsg.content = fullText;
    const trimmed = dropped.trim();
    if (trimmed) {
        fullReasoning += (fullReasoning ? '\n' : '') + trimmed;
        botMsg.reasoning = fullReasoning;
    }
}

三处都要改:SSE 转发层(SilverWolf.py)、前端解析(stores.js)、非流式合并(run_agent)。漏掉非流式那一侧,就会出现"页面上正常、换调用方式少一半内容"。

改动前后同一条问题的对比:

修复前 修复后
回答长度 71 字符 3269 字符
内容 过程话 型号、价格、来源
过程话位置 回答气泡 思考过程面板

六、上下文与 token 成本

成本模型

同一模型、同一天的实测数据:

场景 输入 Token
单轮问答(调用一次时间工具) 4339
3 步联网问答 14478
4 步联网问答 15145

原因是 LLM 接口无状态:Agent 每走一步都要把"到目前为止的全部内容"重新发送一次。成本近似等于 上下文大小 × 轮数,其中被放大的部分有四块:

  1. 每步重发(4 步 = 同一份上下文付 4 次);
  2. 输入占账单主体(15000 Token 里绝大部分是 Prompt);
  3. 工具返回内容体积大(一次搜索 5 条结果加一篇正文,几千字符,且之后每步都跟着重发);
  4. 历史只按条数限制(最早实现是 MAX_HISTORY = 20,20 条长消息可以是两万 Token)。

Token 估算

部分服务商(含 Ollama)不返回 usage,所以需要估算。中日韩字符按 1 字 ≈ 1 token,其余按 4 字符 ≈ 1 token,每条消息额外加 4 个 token 作为角色与结构开销,工具调用的 arguments 同样计入输入:

python 复制代码
# middleware/token_usage.py
_CJK_RE = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]")

def estimate_tokens(text: str) -> int:
    if not text:
        return 0
    cjk = len(_CJK_RE.findall(text))
    others = max(len(text) - cjk, 0)
    return max(int(cjk + others / 4), 1)

def estimate_messages_tokens(messages: list) -> int:
    total = 0
    for m in messages or []:
        content = m.get("content") if isinstance(m, dict) else ""
        total += estimate_tokens(content if isinstance(content, str) else "") + 4
        if isinstance(m, dict) and m.get("tool_calls"):
            for call in m["tool_calls"]:
                fn = (call or {}).get("function", {})
                total += estimate_tokens(str(fn.get("arguments", ""))) + 4
    return total

UsageTracker 优先累加服务商返回的真实值,拿不到时退回估算,最终结果里带一个 source 字段标明这次显示的是实测还是估算值。

裁剪算法

预算定义在整个请求上(system 提示 + RAG 片段 + 历史 + 当前提问),这样界面上显示的"200K 窗口"和实际发送量一致。裁剪从最旧的开始丢,但有两类内容宁可超出预算也要保留:

python 复制代码
# middleware/context.py
def trim_messages(messages, budget_tokens=..., keep_head=2,
                  min_keep_turns=DEFAULT_MIN_KEEP_TURNS, unlimited=False):
    if not messages:
        return [], []
    if unlimited:
        return list(messages), []          # 用户显式选择"无限轮数"

    head = messages[:keep_head]            # system 提示 + 助手开场
    tail = messages[keep_head:]
    last = tail[-1]                        # 当前提问,必留
    history = tail[:-1]

    anchors_cost = _messages_cost(head) + _messages_cost([last])
    min_keep_msgs = max(0, int(min_keep_turns) * 2)      # 一轮 = 一问一答

    kept_history, used = [], 0
    for msg in reversed(history):
        cost = _messages_cost([msg])
        if anchors_cost + used + cost > budget_tokens and len(kept_history) >= min_keep_msgs:
            break                          # 超出预算就停,但先保证保底轮数
        kept_history.append(msg)
        used += cost
    kept_history.reverse()

    dropped = history[: len(history) - len(kept_history)]
    return head + kept_history + [last], dropped

两个参数的作用不同:keep_head 保护固定锚点,min_keep_turns 保证最近若干轮一定在。预算可以调小到 4K,但保底轮数让模型不至于彻底断片。

被裁掉的对话压成摘要

裁掉的内容不会直接丢弃:交给模型压成一段短摘要存进会话,下次作为 system 消息带回。

python 复制代码
prompt = (
    "请把下面这段对话历史压缩成不超过 200 字的中文要点,只保留:用户的目标、"
    "已确认的事实/结论、未完成的事项。不要寒暄,不要复述原文。\n"
    + (f"\n【已有摘要,可合并】\n{previous_summary}\n" if previous_summary else "")
    + f"\n【待压缩的对话】\n{transcript}"
)
resp = client.chat.completions.create(model=provider.get("model"),
                                      messages=[{"role": "user", "content": prompt}],
                                      max_tokens=300, temperature=0.2)

几个限值都写成了常量:单条历史最多取 1200 字符、转录总长 6000 字符、压缩请求 max_tokens=300、摘要最终截到 600 字符。

摘要与旧摘要合并(滚动摘要),存在 sessions.summary 字段;压缩失败只丢历史,不影响这一轮对话。

实测效果:一段 35076 token 的长历史裁剪后剩 6676(其中 996 是必留的锚点),历史部分减少约 90%。

「无限」的表示方法

界面上的「无限轮数」「无限长度」开关,实现上没有使用大数字:

开关 实现
无限轮数 trim_messages 直接返回全部消息;预算只作展示
无限长度 调用模型时不发送 max_tokens 参数

原因是多数服务商对 max_tokens 有硬上限,超了直接返回 400;显式传 null 也有服务商视为非法值。用参数缺席表达「没有额外限制」最稳:

python 复制代码
token_kwargs = {"max_tokens": reply_max_tokens} if reply_max_tokens else {}
stream = client.chat.completions.create(model=model, messages=messages, stream=True, **token_kwargs)

七、联网搜索

后端与降级

后端 依赖 特点
Tavily API Key 结果质量最好,按调用量计费
Bing 结果页解析 免 Key,靠 HTML 结构与浏览器 UA

engine="auto" 时按设置决定:开关打开且配了 Key 就走 Tavily,失败自动降级到爬虫。

爬虫实现里有两个必要的处理:伪装常见浏览器 UA(部分搜索页对 Python 默认 UA 返回空结果),以及限速(两次请求至少间隔 1.5 秒加 0.3-0.8 秒随机抖动):

python 复制代码
_SCRAPE_MIN_INTERVAL = 1.5
_LAST_SCRAPE_TS = 0.0

def throttle() -> None:
    global _LAST_SCRAPE_TS
    now = time.time()
    wait = _SCRAPE_MIN_INTERVAL - (now - _LAST_SCRAPE_TS)
    if wait > 0:
        time.sleep(wait)
    time.sleep(random.uniform(0.3, 0.8))
    _LAST_SCRAPE_TS = time.time()

百度在实测中不可用:无 Cookie 请求会返回"安全验证"页(HTTP 200,正文约 1.4 KB),解析不到结果,所以从可用后端里移除了。

本轮记忆与限流

模型没有"本轮记忆",会把同一个问题换种说法反复搜索。实测一轮出现过 10 次 web_search + 8 次 fetch_page,prompt token 因此涨到 50957。

解决方案是给每轮运行配一个状态盒子(agent/tools/_run_state.py),记录计数与缓存,重复查询直接复用结果:

python 复制代码
run = run_state.state()
cache_key = run_state.normalize_query(query)
if run is not None:
    cached = run["search_cache"].get(cache_key)
    if cached:
        return ("(这个查询你刚才已经搜过,下面是同一批结果,不要再重复搜同一个问题)\n"
                + cached["text"], cached["meta"])
    if run["searches"] >= run["max_searches"]:
        return (f"本轮搜索次数已达上限({run['max_searches']} 次)。"
                "别再调用搜索了 ------ 用已经拿到的资料直接把结论写出来;"
                "确实查不到的部分,就明确说这部分没查到,并给出你建议的下一步。",
                {"ok": True, "query": query, "sources": [], "engine": "limit-reached"})

查询归一化只做去空格、去标点、转小写,不做分词与同义词替换(过于激进的归一化会拦掉"确实需要重新搜"的情况):

python 复制代码
def normalize_query(text: str) -> str:
    text = (text or "").strip().lower()
    for ch in " \t\u3000,,。.、;;::!!??\"'""''()()[]【】<>《》-_/\\|+*#~`":
        text = text.replace(ch, "")
    return text

限流返回的是提示而非错误。返回异常的话模型会当成故障反复重试;给它一句"别再搜了,用已有资料作答",它会转向收尾。上限按"调用次数"计数,因为 max_steps 限制的是轮数,管不住一步之内的并行调用。

状态盒子与线程 :状态本身是一个普通 dict,存在生成器帧里;每步开头通过 contextvars 绑定,供同一步内的工具读取。之所以不"在函数开头绑定一次",是因为 FastAPI 处理同步生成器时把它交给线程池逐步迭代,线程池调用只复制上下文,在线程里 set() 的结果不会回传调用方,第二步就会读不到状态。

结果过滤

修复前一次搜索的前 7 条里有 5 条是工具导航站与百科词条:

复制代码
1. AI 工具集官网 | 1000+ AI 工具集合,国内外 AI 工具集导航大全   https://ai-bot.cn/
2. 免费 AI 工具 - 在线使用免费 AI 工具大全 | AIGC工具导航        https://www.aigc.cn/...
3. AskGo - 免费体验GPT,Claude,Gemini,Grok,DeepSeek              https://askgo.ai/
4. OpenAI _百度百科                                             https://baike.baidu.com/...

搜索引擎按点击率排序,中文 SEO 农场专门针对"2026 最新"这类时间词。因此在返回结果前加一层域名分级与去重:

python 复制代码
def _rank_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """去重 + 过滤农场站 + 官方来源置顶 + 同域名最多 2 条。"""
    seen_urls, per_domain, kept = set(), {}, []
    for item in results:
        url = str(item.get("url") or "").strip()
        if not url or url in seen_urls:
            continue
        seen_urls.add(url)
        domain = urllib.parse.urlsplit(url).netloc.lower()
        if per_domain.get(domain, 0) >= 2:
            continue
        per_domain[domain] = per_domain.get(domain, 0) + 1
        kept.append(item)
    filtered = [r for r in kept if _domain_score(r.get("url", "")) > -100]
    if filtered:                     # 全部被过滤时保留原结果,不返回空列表
        kept = filtered
    return sorted(kept, key=lambda r: -_domain_score(r.get("url", "")))

def _domain_score(url: str) -> int:
    u = (url or "").lower()
    if any(h in u for h in _NAV_FARM_HINTS):     # 导航站 / 工具农场
        return -100
    if any(h in u for h in _LOW_VALUE_HINTS):    # 百科 / 问答
        return -5
    if any(h in u for h in _OFFICIAL_HINTS):     # 厂商官方域名
        return 5
    if any(h in u for h in _NEWS_HINTS):         # 科技与财经媒体
        return 3
    return 0

结果条数同时收敛:默认 3 条、上限 5 条(此前默认 5、上限 10)。改完之后的来源列表:

复制代码
https://openai.com/index/gpt-5-6
https://api-docs.deepseek.com/zh-cn/news/news260910
https://api-docs.deepseek.com/zh-cn/quick_start/pricing
https://www.ithome.com/1/000/719.htm
http://finance.people.com.cn/BIG5/n1/2026/0616/c1004-40741332.html

同一问法的整体对比:

收敛前 收敛后
工具调用 10 次搜索 + 8 次抓页 4 次 + 3 次
单次结果 8~10 条 ≤5 条
prompt token 50957 28812

八、RAG:分块与离线 Embedding

分块

按行拼装,尽量保持段落完整;单行超过块大小时才按字符硬切,切的时候保留重叠,避免把一句关键的话从中间断开:

python 复制代码
# knowledge_base.py,CHUNK_SIZE = 500,CHUNK_OVERLAP = 50
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
    if not text.strip():
        return []
    chunks, current_chunk = [], ""
    for line in text.split("\n"):
        if len(current_chunk) + len(line) + 1 <= chunk_size:
            current_chunk = (current_chunk + "\n" + line).strip()
        else:
            if current_chunk:
                chunks.append(current_chunk)
            if len(line) > chunk_size:                      # 单行超长 → 按字符切
                for i in range(0, len(line), chunk_size - overlap):
                    chunk = line[i:i + chunk_size]
                    if chunk:
                        chunks.append(chunk)
                current_chunk = ""
            else:
                current_chunk = line
    if current_chunk:
        chunks.append(current_chunk)
    return [c.strip() for c in chunks if c.strip()]

检索侧参数:RAG_TOP_K = 5(返回 5 个片段)、RAG_SIMILARITY_THRESHOLD = 0.5(相似度阈值)。

离线 Embedding 兜底

项目的承诺之一是离线可用,而常见的 Embedding 方案需要下载几百 MB 的模型文件。

所以做了两级:优先加载本地 sentence-transformers 模型,找不到时使用内置的字符 n-gram 哈希特征,不下载任何东西:

python 复制代码
# vector_stores.py
class LocalHashEmbeddingFunction:
    """
    基于字符 n-gram 的确定性 Hash 特征向量。
    特征:unigram(权重 1) + bigram(权重 2) + trigram(权重 3),计数累加;
    维度固定 1024(与 bge-large-zh 对齐),向量做 L2 归一化。
    """
    def _hash_index(self, prefix: str, token: str) -> int:
        digest = hashlib.md5((prefix + token).encode("utf-8")).digest()
        return int.from_bytes(digest[:4], "big") % self.dimension

    def _embed_one(self, text: str) -> List[float]:
        vec = [0.0] * self.dimension
        s = (text or "").lower()
        for ch in s:
            vec[self._hash_index("1:", ch)] += 1.0
        for i in range(len(s) - 1):
            vec[self._hash_index("2:", s[i:i + 2])] += 2.0
        for i in range(len(s) - 2):
            vec[self._hash_index("3:", s[i:i + 3])] += 3.0
        norm = math.sqrt(sum(v * v for v in vec))
        return [v / norm for v in vec] if norm > 0 else vec

前缀("1:""2:""3:")用来隔离不同阶的特征,避免 bigram 与 trigram 撞到同一个桶。

这套方案的检索质量低于神经模型,但它是"离线可用"这条承诺的兜底路径,启动日志里会明确打印当前用的是哪一级:

复制代码
[WARNING] vector_stores: Embedding 后端: local-hash(内置离线,零下载)
          (未找到本地 Transformer 模型,使用内置离线 Embedding)

Collection 命名与元数据校验

Chroma 的 collection 名有合法字符限制,且 Embedding 模型或维度变化会让已有向量失效。

处理方式是把 kb 的 slug 转成稳定 ASCII 名称,并把 Embedding 元数据写进 collection:

python 复制代码
def _make_collection_name(kb_slug: str) -> str:
    """仅允许 ASCII [a-zA-Z0-9._-],不符合要求时替换为下划线。"""
    safe_chars = []
    for ch in kb_slug:
        if ch.isascii() and (ch.isalnum() or ch in "._-"):
            safe_chars.append(ch)
        else:
            safe_chars.append("_")
    return f"kb_{''.join(safe_chars)}"

def _validate_collection_metadata(collection) -> None:
    meta = collection.metadata or {}
    stored_model = meta.get("embedding_model", "")
    stored_dim = meta.get("embedding_dimension", 0)
    if stored_model and stored_model != EMBEDDING_MODEL_NAME:
        raise RuntimeError(f"Embedding 模型不匹配:collection 使用 {stored_model},"
                           f"当前配置为 {EMBEDDING_MODEL_NAME}。请重建知识库。")
    if stored_dim and stored_dim != EMBEDDING_DIMENSION:
        raise RuntimeError(f"Embedding 维度不匹配:collection 为 {stored_dim} 维,"
                           f"当前配置为 {EMBEDDING_DIMENSION} 维。请重建知识库。")

维度不一致时直接报错而不是静默新建 collection。静默处理会导致"检索看起来在工作,但结果一直是乱的"这类难查的问题。

九、数据持久化

三张表:会话、消息、附件引用。消息表用 meta 字段存 JSON,把 Token 用量、工具调用、搜索来源、RAG 命中一起落库,刷新页面后气泡底部的统计仍然完整。

python 复制代码
def init_db() -> None:
    with get_db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS sessions (
                id TEXT PRIMARY KEY,
                title TEXT NOT NULL DEFAULT 'New Chat',
                created_at INTEGER NOT NULL,
                updated_at INTEGER NOT NULL
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
            )
        """)
        # SQLite 不支持 IF NOT EXISTS 加列,用 try/except 做幂等迁移
        try:
            conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
        except sqlite3.OperationalError:
            pass
        try:
            conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
        except sqlite3.OperationalError:
            pass

附件的策略是把文件落在 data/uploads/<日期>/,数据库只存引用(路径、名字、大小、SHA256),删除会话时按引用清文件:

python 复制代码
conn.execute("""
    CREATE TABLE IF NOT EXISTS attachments (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id TEXT NOT NULL,
        path TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0,
        sha256 TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL,
        FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
    )
""")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_session_path "
             "ON attachments(session_id, path)")

构建上下文时的消息顺序

python 复制代码
def build_messages(session_id, user_message, system_prompt, assistant_greeting, max_history=MAX_HISTORY):
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "assistant", "content": assistant_greeting},
    ]
    rows = conn.execute("SELECT role, content FROM messages WHERE session_id = ? "
                        "ORDER BY id DESC LIMIT ?", (session_id, max_history)).fetchall()
    messages += [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
    messages.append({"role": "user", "content": user_message})
    return messages

顺序是 system 提示、助手开场、历史、当前提问。RAG 片段与长期记忆通过 insert(2, ...) 插在开场之后,这个位置选择带出一个已知问题,写在最后一节。

十、权限与安全边界

工作区沙箱

模型能读写的是 data/workspace/ 一个目录,所有文件工具的入口都先过同一个解析函数:

python 复制代码
# agent/tools/workspace.py
def _safe_resolve(rel_path: str) -> Path:
    raw = (rel_path or "").strip().replace("\\", "/")
    if not raw or raw in (".", "./"):
        return WORKSPACE_DIR.resolve()
    if raw.startswith("/") or ":" in raw.split("/")[0]:
        raise ValueError("不允许使用绝对路径,请用相对工作区的路径")
    target = (WORKSPACE_DIR / raw).resolve(strict=False)
    if not target.is_relative_to(WORKSPACE_DIR.resolve()):
        raise ValueError("路径逃逸出工作区,被拦下了(不许用 .. 或符号链接出界)")
    return target

resolve(strict=False) 会展开 .. 与符号链接,随后判断展开结果是否仍在根目录内。

这一条同时覆盖相对路径穿越、绝对路径、Windows 盘符和软链接出界。

删除只允许单文件或空目录,非空目录直接拒绝,避免误操作清空工作区。

高危操作审批

写文件、删文件、增删改自定义工具这四类操作,执行前弹窗等待用户确认。实现是一次基于 SSE 的请求-响应:服务端发事件并等待,前端弹窗把结果 POST 回来。

python 复制代码
# agent/runner.py
APPROVAL_REGISTRY: Dict[str, Dict[str, Any]] = {}
APPROVAL_TIMEOUT = 60.0

spec = agent_tools.BUILTIN_TOOLS.get(c["name"]) or {}
if spec.get("approval_required"):
    request_id = f"apv_{uuid.uuid4().hex[:12]}"
    APPROVAL_REGISTRY[request_id] = {"approved": False, "event": threading.Event()}
    yield {"type": "tool_approval", "request_id": request_id,
           "name": c["name"], "args": c["args"]}
    waited = APPROVAL_REGISTRY[request_id]["event"].wait(APPROVAL_TIMEOUT)
    approved = APPROVAL_REGISTRY.get(request_id, {}).get("approved", False) if waited else False
    APPROVAL_REGISTRY.pop(request_id, None)
    if not approved:
        reason = "用户拒绝了这次操作" if waited else f"等待批准超时({int(APPROVAL_TIMEOUT)} 秒),已自动拒绝"
        tools_used.append({"name": c["name"], "args": c["args"], "ok": False, "denied": True})
        yield {"type": "tool_result", "name": c["name"], "ok": False, "summary": reason,
               "meta": {"denied": True, "tool": c["name"]}}
        messages.append({
            "role": "tool",
            "tool_call_id": c["id"],
            "content": f"工具 {c['name']} 被跳过:{reason}。"
                       "向用户说明情况,改用其它方式或等用户同意后再试。",
        })
        continue

回调接口只做一件事:写标记、置事件。

python 复制代码
@router.post("/api/agent/approval")
async def api_agent_approval(req: ApprovalRequest):
    entry = agent_runner.APPROVAL_REGISTRY.get(req.request_id)
    if not entry:
        return {"ok": False, "error": "审批请求不存在或已超时"}
    entry["approved"] = req.approve
    entry["event"].set()
    return {"ok": True}

被拒绝时返回的是"这一步被跳过"的普通工具结果,模型可以改方案,整轮对话不会因为一次拒绝而中断。

计算器:AST 白名单

模型的算术不可靠,需要给它计算能力,但 eval() 等于交出整台机器。实现方式是解析成 AST 后逐节点白名单放行:

python 复制代码
# agent/tools/calculator.py
_BIN_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
            ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
            ast.Mod: operator.mod, ast.Pow: operator.pow}
_SAFE_FUNCS = {"abs": abs, "round": round, "min": min, "max": max, "pow": pow, "sum": sum,
               "sqrt": math.sqrt, "floor": math.floor, "ceil": math.ceil,
               "log": math.log, "log10": math.log10, "log2": math.log2,
               "sin": math.sin, "cos": math.cos, "tan": math.tan,
               "degrees": math.degrees, "radians": math.radians,
               "factorial": math.factorial, "gcd": math.gcd}
_SAFE_NAMES = {"pi": math.pi, "e": math.e, "tau": math.tau}

def _eval_node(node: ast.AST):
    if isinstance(node, ast.Constant):
        if isinstance(node.value, (int, float, complex, bool)):
            return node.value
        raise ValueError(f"不支持的常量类型:{type(node.value).__name__}")
    if isinstance(node, ast.Name):
        if node.id in _SAFE_NAMES:
            return _SAFE_NAMES[node.id]
        raise ValueError(f"不允许使用变量:{node.id}")
    if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
        return _BIN_OPS[type(node.op)](_eval_node(node.left), _eval_node(node.right))
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
        if node.func.id not in _SAFE_FUNCS:
            raise ValueError(f"不允许调用的函数:{node.func.id}")
        if node.keywords:
            raise ValueError("不支持关键字参数")
        return _SAFE_FUNCS[node.func.id](*[_eval_node(a) for a in node.args])
    raise ValueError(f"不支持的表达式形式:{type(node).__name__}(只允许数值运算)")

属性访问、下标、赋值、循环、导入都会落到最后一个分支被拒绝。

留痕

所有自我修改动作(写知识库、写工作区、增删改自定义工具)都经过同一个记录函数,写进 data/agent_changes.log(JSON Lines),设置页可以查看最近的记录:

python 复制代码
def log_change(action: str, detail: Dict[str, Any]) -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    line = json.dumps({"ts": int(time.time()), "action": action, "detail": detail},
                      ensure_ascii=False)
    with open(CHANGES_LOG, "a", encoding="utf-8") as f:
        f.write(line + "\n")

十一、跨会话长期记忆

存储在 data/memory.json,条目结构包含 id、内容、标签、时间戳、来源。检索不使用向量库,用相邻双字 bigram 的重叠比例打分:

python 复制代码
def _content_terms(text: str) -> set:
    """英文/数字按单词切,中文按相邻双字切 bigram。"""
    text = (text or "").lower()
    terms = set(re.findall(r"[a-z0-9]+", text))
    for han in re.findall(r"[\u4e00-\u9fff]+", text):
        terms.update(han[i:i + 2] for i in range(max(0, len(han) - 1)))
    return terms

def _match_score(query: str, content: str) -> float:
    q_terms = _content_terms(query)
    if not q_terms:
        return 0.0
    content_lower = (content or "").lower()
    hits = [t for t in q_terms if t in content_lower]
    if not hits:
        return 0.0
    base = len(hits) / len(q_terms)                          # 命中占比
    bonus = min(1.0, max(len(t) for t in hits) / 8.0) * 0.15  # 更长的命中词给一点加权
    return base + bonus

中文没有空格分隔,单字命中噪声太大,整句匹配又太严,双字是成本最低的折中。单条记忆长度限制 2000 字符,防模型把整段对话塞进去;召回默认最多 5 条,一次注入过多的记忆同样是浪费上下文。

选这个方案的理由是数据规模:记忆条目通常是一两句话,bigram 重叠在这个粒度上足够;语义检索由 RAG 那条链路承担,没必要为轻量功能再加载一个模型进内存。

十二、前端实现

无构建带来的两个具体问题

模板是字符串 。组件写在 app.js 里,模板用反引号包裹,改一处模板不需要重新构建,但也没有编译期检查。补的手段是在 Node 里用仓库自带的 Vue 运行时编译全部模板,做一次静态校验。

响应式只在代理上生效。流式输出时如果直接修改 push 之前的原始对象,界面不会更新;必须拿到数组里的响应式代理再改:

javascript 复制代码
// 正确:从响应式数组里取最后一条(拿到的是代理)
const botMsg = this.messages[this.messages.length - 1];
botMsg.content += chunk;

这条是项目早期"流式输出不显示、刷新后才出现"的根因之一。

停止生成

前端持有当前请求的 AbortController,发送按钮在生成期间切换为停止键:

javascript 复制代码
this.abortController = new AbortController();
const response = await fetch('/api/chat/stream', {
    method: 'POST', body: JSON.stringify(payload),
    signal: this.abortController.signal,
});
...
stopGenerating() {
    if (this.abortController) {
        try { this.abortController.abort(); } catch (e) { /* ignore */ }
    }
    this.sending = false;
    const last = this.messages[this.messages.length - 1];
    if (last && last.role === 'bot' && last.streaming) {
        last.streaming = false;
        if (!last.content) last.content = '(已停止生成)';
        last.stopped = true;
    }
}

中断在服务端的表现(生成器被关闭)带出一个已知问题,见最后一节。

图标系统

界面不用 emoji 当图标,改用自绘的 16 × 16 像素风 SVG,统一由 icons.js 提供组件与 favicon 生成函数。好处是颜色跟随主题变量、渲染体积小、不会因为系统 emoji 字体不同而走形。

十三、测试

三层,成本从低到高。

静态检查

bat 复制代码
.venv\Scripts\python.exe -m py_compile SilverWolf.py agent\runner.py
node --check frontend\src\app.js
node --check frontend\src\stores.js

py_compile 只要几十毫秒,但能拦住全部语法与缩进错误。曾经有一次用补丁工具改代码,匹配位置出错,一段调用被复制到文件末尾,拿到 IndentationError: unexpected indent

错误被推迟到启动服务才暴露,中间还白跑了一轮测试。之后把"改完立刻编译"变成固定动作。

pytest 冒烟

tests/test_smoke.py 目前 12 个用例,覆盖容易在重构中坏掉的链路:

用例 覆盖内容
工具注册表 关键工具是否都被自动发现
权限标记 写/删类工具必须带 approval_required
长期记忆 remember → recall → forget 全链路,无关话题不被召回
工作区沙箱 路径穿越、绝对路径、盘符被拦;写→读→删正常
计算器 正常算术结果 + 恶意表达式全部被拒
任务计划 状态机与非法状态归一化
审批链路 用假 OpenAI 客户端驱动真实循环:允许则写盘,拒绝则不写盘
SSE 归一化 空 choices、delta.content 为 list 的处理

审批用例的做法是给 runner 换一个假客户端,按剧本返回"要求调用工具"再"给出回答",这样不用真联网也能验证完整循环。假客户端用一个逐步给出帧的迭代器模拟流式响应:

python 复制代码
class _FakeStream:
    """第 1 步返回一个 fs_write 的工具调用,第 2 步返回普通文本。"""
    def __init__(self, step):
        self.step = step
        self.frames = list(self._frames())

    def _frames(self):
        if self.step == 1:
            yield _chunk(_delta_fn("fs_write", '{"path": "ap_test.txt", "content": "hi"}', "c1"))
        else:
            yield _chunk(_delta_txt("写完了。"))

    def __iter__(self):
        return self

    def __next__(self):
        if self.ix < len(self.frames):
            f = self.frames[self.ix]; self.ix += 1
            return f
        raise StopIteration

class _FakeCompletions:
    def __init__(self, cli): self.cli = cli
    def create(self, **kw): self.cli.calls += 1; return _FakeStream(self.cli.calls)

审批的响应由另一个线程在 0.3 秒后触发,模拟用户点按钮:

python 复制代码
def _run_agent_with_approval(fake_client, approve, monkeypatch):
    events, feed = [], {"rid": None}

    def respond():
        time.sleep(0.3)
        entry = runner.APPROVAL_REGISTRY.get(feed["rid"])
        entry["approved"] = approve
        entry["event"].set()

    t = threading.Thread(target=respond); t.start()
    for ev in runner.run_agent_stream(provider, messages,
                                      enabled_tools=["fs_write"], max_steps=2):
        events.append(ev)
        if ev["type"] == "tool_approval":
            feed["rid"] = ev["request_id"]
    t.join(timeout=5)
    return events

def test_approval_allowed_writes_file(fake_client, clean_workspace, monkeypatch):
    events = _run_agent_with_approval(fake_client, approve=True, monkeypatch=monkeypatch)
    assert "tool_approval" in [e["type"] for e in events]
    f = workspace.WORKSPACE_DIR / "ap_test.txt"
    assert f.exists() and f.read_text(encoding="utf-8") == "hi"
    assert not runner.APPROVAL_REGISTRY, "审批注册表未清理"

用例都带清理 fixture(fake_client / clean_workspace / clean_memory):

跑之前备份、跑之后恢复自己碰过的数据文件,不残留、不动用户数据。

真实浏览器

涉及交互的功能(设置弹窗、模型下拉、审批弹窗、附件上传)用真实 Chrome 验证,通过 CDP 驱动:

python 复制代码
targets = httpx.get("http://127.0.0.1:9227/json/list").json()
page = next(t for t in targets if t["type"] == "page" and "127.0.0.1:8010" in t["url"])
ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=30, suppress_origin=True)

def js(expr):
    ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "returnByValue": True}}))
    ...

js("document.querySelector('.settings-fab').click()")

两个具体问题:

  • 新版 Chrome 会拒绝带 Origin 头的 WebSocket 握手(403),需要在客户端 suppress_origin=True,或在启动参数里加 --remote-allow-origins
  • 截图容易拿到黑图或只拍一角:需要先发 Page.enable,并确认 viewport 足够大。因此断言以"读 DOM 文本"为主,截图只用于给人看。

十四、已知问题

以下几项已经定位但尚未修复,细节与修复方案记录在仓库的 changecode.md 第 7 节:

  1. 注入的 RAG 片段与长期记忆会被裁剪最先丢掉 。它们被插在消息列表第 2、3 位,
    而裁剪只保护前 keep_head 条,于是落在"可裁剪历史"里并且是第一批被丢弃的。
    实测:18603 token 的上下文,预算压到 17900 时,最先丢掉的两条正是「长期记忆」与
    「知识库检索结果」。修法是让裁剪把 system 角色当作锚点,或按开头连续的 system 消息计算 keep_head
  2. token 硬闸分支绕过了 demote,触发硬闸那一轮的过程话会留在回答气泡里。
  3. 「停止生成」之后这一轮不落库 。浏览器中断导致服务端生成器被关闭,
    保存代码写在正常路径上,GeneratorExit 也不会被 except Exception 捕获。
  4. 审批等待会占用线程池工作线程 ,最长 60 秒;另外审批范围不一致------
    工作区写文件需要允许,知识库写入不需要。
  5. 工具层缺少资源上限 。计算器允许 2**100000000 这类表达式,
    fs_write 也没有单文件大小限制。

十五、小结

把工具接上模型之后,工作量集中在几个不显眼的地方:

  1. 状态 。模型不记得自己刚才搜过什么,所以框架要提供本轮状态(计数、缓存),
    并且要注意它存放的位置会不会被线程池或上下文切换吃掉。
  2. 输出的语义分层content 里既有结论也有过程,
    框架必须把它们分开,并保证"轮数用尽"这类退出路径也能产出结论。
  3. 成本 。无状态接口让每一步都重发上下文,成本随上下文大小与轮数相乘增长,
    限制条数、截断工具结果、裁剪历史、设置硬闸都属于同一件事。
  4. 权限。一旦能改文件,沙箱、审批、审计就从"加分项"变成"能不能给人用的前提"。
  5. 可回滚 。每一步一个提交、每个坑一篇记录,出问题时能定位到具体改动,
    也让这套系统在被替换(换模型、换工具、换前端)时仍然可维护。

项目仓库为 S_Wolf(MIT)。角色「银狼」来自《崩坏:星穹铁道》,

仓库不含任何官方素材,人设只影响交互文案的措辞。

相关推荐
水如烟1 小时前
孤能子视角:蓝星文明篇·异——正当性叙事的裂缝:负续指的聚合与相变
人工智能
美狐美颜SDK开放平台1 小时前
直播APP源码与视频美颜sdk如何配合?一套完整开发思路
android·人工智能·计算机视觉·音视频·直播美颜sdk
不会写代码的女程序猿1 小时前
商用 AI 四诊仪技术拆解|明理 AI 四诊仪多模态采集方案解析
大数据·人工智能·科技·ai·健康医疗
IT_陈寒1 小时前
Redis大key删除引发的服务雪崩,这次我真记住了
前端·人工智能·后端
正经教主1 小时前
【FDE系列】阶段2:Day 47:对接企业系统 — 飞书 / 钉钉 API
人工智能·docker·fde
Dawson Zhu1 小时前
从 Jev 说起:快慢模型如何协同调度,以及这背后需要解决什么问题
人工智能·语言模型·架构·aigc·agi
小陈phd2 小时前
深入理解agent学习笔记(一)——现代agent介绍
人工智能·算法
渡我白衣2 小时前
HttpRequest与HttpResponse的实现
服务器·数据结构·c++·人工智能·tcp/ip·机器学习·caffe
AAIshangyanxiu2 小时前
智能气候前沿:AI Agent结合机器学习与深度学习在全球气候变化驱动因素预测中的应用
人工智能·agent·气候变化·智能气候前沿