【Java/Go后端手撸原生Agent(第七篇):Token预算管理 + 滑动窗口上下文裁剪】

系列目录:Phase 1-2 基础框架Phase 3 状态机+文件读取Phase 4-6 原生FC+Judge校验Phase 5 多工具并行+BashToolPhase 6 流式输出Phase 7 Token预算与上下文管理(本文)

上一篇链接:Java/Go后端手撸原生Agent(第六篇):流式输出让Agent思考过程"看得见"

前言

前六篇我们完成了ReAct智能体的全部核心链路:状态机驱动、原生Function Calling、多工具并行、BashTool执行引擎、LLM-as-Judge校验、流式SSE输出。到这里Agent已经能跑通完整的"提问→思考→调工具→观察→再思考→回答→校验"闭环了。

但跑长对话时会遇到一个所有LLM应用都绕不开的硬约束------上下文窗口(Context Window)是有限的 。你的模型可能是8K、32K、128K窗口,但无论多大,总有上限。当前的ShortMemory是无限增长的:每轮对话加user+assistant+tool消息,BashTool一次find . -name "*.py"可能输出几千字符,对话五六个回合后messages数组就可能膨胀到爆,API返回context_length_exceeded错误直接崩溃。

本文解决这个问题。核心做两件事:

  1. Token精确计数:不做中文/1.5这种粗糙估算,用tiktoken精确计算每条消息、tools schema、system prompt的token开销;
  2. 滑动窗口裁剪 :超预算时从最早的消息开始裁剪,但有一个关键约束------不能破坏tool_calls和tool结果消息的配对完整性,否则API会拒绝请求。

前置说明:

  • 新增文件:agent/token_counter.py(token计数模块);
  • 改造文件:agent/memory.py(ShortMemory增加token感知和滑动窗口裁剪);main.py(每轮THINKING前做预算检查);
  • 零改动文件:llm_client.pytools/下所有工具、Judge校验逻辑------这些模块不需要感知token管理。

一、问题本质:token是LLM世界的"内存"

1.1 粗估为什么不行

很多Demo里"token估算"是这么做的:len(text) // 2(英文)或len(text) // 1.5(中文)。这种估算在只计算content文本时勉强能用,但在Agent场景下严重失准,因为每次API请求的token开销由四部分组成:

复制代码
总token = system prompt + tools schema + messages历史 + 输出预留

其中tools schema是最容易被忽略的隐性开销。你的Agent有3个工具(calculator/read_file/bash),每个工具的function定义(name/description/parameters JSON schema)就要几百token。如果工具数量增长到10个,tools schema轻松破2000 token。用len(text)//2完全估算不到这部分。

而且OpenAI协议里每条消息有固定开销:<|im_start|>role\n + content + <|im_end|>\n,大约4 token/条。消息数量多了这部分也不能忽略。

1.2 裁剪的真正难点:不是删消息,是保护配对完整性

直觉上"上下文满了从前面删"不就行了?不行。OpenAI协议有硬约束:

  • role=tool消息必须紧跟在包含对应tool_callsassistant消息后面;
  • 每条tool消息通过tool_call_id精确配对到assistant消息里的某个tool_call;
  • 如果删了assistant(tool_calls)而留下它的tool结果消息,tool消息悬空,API报错;
  • 如果删了tool结果而留下assistant(tool_calls),assistant引用了不存在的工具结果,API同样报错。

举个例子,合法的消息序列:

复制代码
user: "帮我读文件并计算"
assistant(tool_calls=[{id:call_1, name:read_file}, {id:call_2, name:calculator}]): "我先读文件再计算"
tool(tool_call_id=call_1): "文件内容是..."
tool(tool_call_id=call_2): "计算结果: 42"
user: "[Judge反馈] 你还没回答完整..."
assistant: "根据文件内容和计算结果..."

如果你只删前两条(user+assistant),留下两个tool消息在最前面,这两个tool没有对应的assistant(tool_calls)在前面,协议断裂。如果你只删第一个tool消息,留下assistant(tool_calls=call_1, call_2)和tool(call_2),call_1的结果丢失,同样断裂。

所以裁剪单位不能是"一条消息",必须是"一个消息块"。

1.3 三种策略的分层选择

上下文管理有三种策略,从轻到重:

策略 原理 可靠性 复杂度
滑动窗口(本文做) 从最早开始删完整消息块,超预算就删 确定性,不调LLM
摘要压缩(下一步做) 被删的消息用LLM总结成一段话,塞回历史开头 保留语义但丢失细节,依赖LLM
重要性排序/RAG(未来) 按消息与当前问题的相关性选择保留,向量检索 最优但复杂,需要向量数据库

本次只做滑动窗口。原因:它是确定性代码、零额外LLM调用、是所有更高级策略的基础(摘要压缩也需要先确定哪些消息要被摘要)。按"先验证再固化"原则,先用最简方案跑通,等真实遇到"裁掉了重要上下文"的场景再加摘要。

二、改造1:Token精确计数模块

2.1 tiktoken:OpenAI官方tokenizer

Token计数不需要自己写规则,用OpenAI官方的tiktoken库:

bash 复制代码
pip install tiktoken

tiktoken内置了GPT-4/GPT-3.5使用的BPE tokenizer(cl100k_base),大多数兼容OpenAI协议的模型(DeepSeek/Qwen/GLM等)也使用兼容的tokenizer,误差可接受。

2.2 计数规则

参考OpenAI官方cookbook,精确计数需要考虑:

  • 每条消息固定4 token(role标记 + 开始/结束符);
  • content、tool_call的name/id/arguments分别序列化后计数;
  • tools schema整体序列化为JSON后计数,每个tool额外6 token结构开销;
  • 整个请求额外2 token前缀(assistant回复引导token)。

2.3 agent/token_counter.py完整代码

python 复制代码
"""
Token计数模块。

为什么需要精确计数?
LLM的上下文窗口是硬限制(token数上限),超出会报context_length_exceeded错误。
粗估("中文字符/1.5")对content文本还行,但tool_calls JSON结构和tools schema的
开销不可忽略------10个工具的schema可能就占2000+ tokens。

计数规则参考OpenAI官方cookbook:
- 每条消息固定开销 ~4 tokens(<|im_start|>role\n...<|im_end|>\n)
- tool_calls序列化为JSON后按文本计数
- tools schema同样序列化后计数
- 最后有一个固定开销 ~2 tokens(assistant回复前缀)
"""
from __future__ import annotations

import json
from typing import Any, Optional

import tiktoken

# cl100k_base: GPT-4/GPT-3.5-turbo(0613及之后版本)使用的tokenizer
# 兼容OpenAI协议的大多数模型也用这个(DeepSeek/Qwen等)
_ENCODING = tiktoken.get_encoding("cl100k_base")

# 每条消息的固定开销token数(role标记+开始/结束符)
_TOKENS_PER_MESSAGE = 4
# 整个请求的固定前缀开销(assistant回复引导token)
_TOKENS_PER_REQUEST = 2
# tool_call固定开销(type/function/id字段名+结构开销)
_TOKENS_PER_TOOL_CALL = 8


def count_text(text: str) -> int:
    """计算一段文本的token数"""
    return len(_ENCODING.encode(text))


def count_tool_call(tool_call: dict[str, Any]) -> int:
    """
    计算单个tool_call的token数。
    tool_call是OpenAI格式: {"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}
    """
    tokens = _TOKENS_PER_TOOL_CALL
    fn = tool_call.get("function", {})
    name = fn.get("name", "")
    arguments = fn.get("arguments", "")
    if name:
        tokens += count_text(name)
    if arguments:
        if isinstance(arguments, str):
            tokens += count_text(arguments)
        else:
            tokens += count_text(json.dumps(arguments, ensure_ascii=False))
    call_id = tool_call.get("id", "")
    if call_id:
        tokens += count_text(call_id)
    return tokens


def count_message(msg: dict[str, Any]) -> int:
    """计算单条消息的token数(含固定开销)"""
    tokens = _TOKENS_PER_MESSAGE

    role = msg.get("role", "")
    content = msg.get("content")
    if content:
        tokens += count_text(str(content))

    if role == "assistant":
        tool_calls = msg.get("tool_calls")
        if tool_calls:
            for tc in tool_calls:
                tokens += count_tool_call(tc)

    if role == "tool":
        tool_call_id = msg.get("tool_call_id", "")
        if tool_call_id:
            tokens += count_text(tool_call_id)

    return tokens


def count_messages(messages: list[dict[str, Any]]) -> int:
    """计算一组消息的总token数(不含tools schema和request前缀)"""
    total = 0
    for msg in messages:
        total += count_message(msg)
    return total


def count_tools_schema(tools: Optional[list[dict[str, Any]]]) -> int:
    """计算tools参数(Function Schema数组)的token数"""
    if not tools:
        return 0
    total = 0
    for tool in tools:
        total += count_text(json.dumps(tool, ensure_ascii=False))
        total += 6  # 每个tool的固定结构开销
    return total


def count_request(
    system_prompt: Optional[str],
    messages: list[dict[str, Any]],
    tools: Optional[list[dict[str, Any]]] = None,
) -> int:
    """
    计算整个API请求的token数(不含输出token)。
    总token = system_prompt开销 + messages开销 + tools schema开销 + 请求前缀开销
    """
    total = 0
    if system_prompt:
        total += _TOKENS_PER_MESSAGE
        total += count_text(system_prompt)
    total += count_messages(messages)
    total += count_tools_schema(tools)
    total += _TOKENS_PER_REQUEST
    return total

模块职责单一:只做计数,不做裁剪。每个函数粒度足够细,count_text/count_message/count_messages/count_tools_schema分别对应不同层级的计数需求,调用方按需组合。

三、改造2:ShortMemory增加滑动窗口裁剪

3.1 消息块的定义

这是核心设计。裁剪单位是"消息块",块的规则:

第一条消息的类型 块组成
user消息 单条为一个块
assistant消息(无tool_calls) 单条为一个块
assistant消息(有tool_calls) assistant + 紧随其后、tool_call_id匹配的所有tool消息 = 一个块
tool消息(异常) 单条删除(不应该出现在块首,属于断裂数据)

为什么assistant(tool_calls)后面的tool消息可能不是全部匹配?在正常流程中,assistant(tool_calls)后面紧跟的tool消息就是它的全部结果,且tool_call_id一一对应。但考虑到边缘情况(比如之前有异常删除导致历史中间出现断裂),算法只收集tool_call_id匹配的tool消息,遇到非tool消息或所有ID匹配完毕就停止。

3.2 _pop_oldest_block算法

python 复制代码
def _pop_oldest_block(self) -> int:
    """删除最早的一个完整消息块,返回删除的token数"""
    if not self.history:
        return 0

    first = self.history[0]

    # 独立消息块:user 或 assistant(无tool_calls)
    if first["role"] == "user" or (first["role"] == "assistant" and not first.get("tool_calls")):
        self.history.pop(0)
        return count_message(first)

    # 工具调用块:assistant(tool_calls) + 其对应的tool结果消息
    if first["role"] == "assistant" and first.get("tool_calls"):
        tc_ids = {tc["id"] for tc in first["tool_calls"]}
        self.history.pop(0)  # 先删assistant
        removed = count_message(first)

        # 然后删除紧随其后、tool_call_id匹配的tool消息
        i = 0
        while i < len(self.history) and self.history[i]["role"] == "tool":
            msg_tc_id = self.history[i].get("tool_call_id")
            if msg_tc_id in tc_ids:
                removed_msg = self.history.pop(i)  # pop后下一个元素移到i位置,不要递增i
                removed += count_message(removed_msg)
                tc_ids.discard(msg_tc_id)
                if not tc_ids:
                    break  # 所有配对的tool消息都删完了
            else:
                i += 1
        return removed

    # 异常:tool消息在最前面(配对断裂),单独删除
    removed = self.history.pop(0)
    return count_message(removed)

注意pop(i)后不要递增i------pop之后原来的i+1位置元素移到了i位置,需要继续检查i位置。这和遍历list时删除元素的经典坑一样。

3.3 agent/memory.py完整代码

python 复制代码
"""
短期对话记忆模块(Phase 9:token感知+滑动窗口裁剪)。

核心概念:
- 消息role严格对齐OpenAI Chat Completions API协议(user/assistant/tool)
- system不存入memory,每轮THINKING动态拼接
- 每条消息都有token成本,LLM上下文窗口是硬限制
- 裁剪单位是"消息块"而非单条消息,保护tool_calls↔tool_result的配对完整性

配对完整性约束(OpenAI协议硬要求):
- assistant消息如果有tool_calls,后面必须紧跟对应tool_call_id的tool结果消息
- 不能只删assistant(tool_calls)而留下它的tool结果(tool消息会悬空)
- 也不能只删tool结果而留下assistant(tool_calls)(assistant会引用不存在的工具结果)
- 所以裁剪时要么删整个块[assistant(tc) + tool*N],要么删独立消息(user/assistant无tc)
"""
from __future__ import annotations

from typing import Any, Optional

from agent.token_counter import count_message, count_messages


class ShortMemory:
    """短期对话记忆,消息格式直接对齐OpenAI API,带token预算管理"""

    def __init__(self, max_tokens: int = 6000):
        """
        :param max_tokens: memory中messages部分的token上限
            (不含system prompt和tools schema,那些由调用方在外部预算中扣除)
        """
        self.history: list[dict[str, Any]] = []
        self.max_tokens = max_tokens
        self.truncated_blocks = 0

    def add_user(self, content: str):
        self.history.append({"role": "user", "content": content})

    def add_assistant(self, content: Optional[str], tool_calls: Optional[list[dict]] = None):
        msg: dict[str, Any] = {"role": "assistant", "content": content}
        if tool_calls:
            msg["tool_calls"] = tool_calls
        self.history.append(msg)

    def add_tool_result(self, tool_call_id: str, content: str):
        self.history.append({
            "role": "tool",
            "tool_call_id": tool_call_id,
            "content": content,
        })

    def get_messages(self) -> list[dict]:
        return [m.copy() for m in self.history]

    def remove_last(self) -> Optional[dict]:
        if self.history:
            return self.history.pop()
        return None

    def token_count(self) -> int:
        """当前memory中所有消息的token总数"""
        return count_messages(self.history)

    def _pop_oldest_block(self) -> int:
        """
        删除最早的一个完整消息块,返回删除的token数。
        块定义见文件头注释。
        """
        if not self.history:
            return 0

        first = self.history[0]

        # 独立消息块:user 或 assistant(无tool_calls)
        if first["role"] == "user" or (first["role"] == "assistant" and not first.get("tool_calls")):
            self.history.pop(0)
            return count_message(first)

        # 工具调用块:assistant(tool_calls) + 其对应的tool结果消息
        if first["role"] == "assistant" and first.get("tool_calls"):
            tc_ids = {tc["id"] for tc in first["tool_calls"]}
            self.history.pop(0)
            removed = count_message(first)

            i = 0
            while i < len(self.history) and self.history[i]["role"] == "tool":
                msg_tc_id = self.history[i].get("tool_call_id")
                if msg_tc_id in tc_ids:
                    removed_msg = self.history.pop(i)
                    removed += count_message(removed_msg)
                    tc_ids.discard(msg_tc_id)
                    if not tc_ids:
                        break
                else:
                    i += 1
            return removed

        # 异常:tool消息在最前面(配对断裂),单独删除
        removed = self.history.pop(0)
        return count_message(removed)

    def truncate(self, reserve_tokens: int = 0) -> bool:
        """
        裁剪最早的消息块,直到memory的token数在max_tokens - reserve_tokens以内。

        :param reserve_tokens: 需要为外部内容预留的token数(system prompt + tools schema等)
        :return: 是否发生了裁剪
        """
        budget = self.max_tokens - reserve_tokens
        if budget < 0:
            budget = 0

        truncated = False
        while self.history and count_messages(self.history) > budget:
            removed = self._pop_oldest_block()
            if removed == 0:
                break
            truncated = True
            self.truncated_blocks += 1

        return truncated

几个设计决策说明:

  • max_tokens是messages部分的预算,不是总预算。system prompt和tools schema的开销由调用方在truncate时通过reserve_tokens传入。memory不感知外部开销,只管理自己管的messages;
  • add/add_assistant/add_tool_result不触发自动裁剪。因为添加消息时还不知道这一轮的外部开销(system prompt有没有变化?tools schema这一轮带不带?),裁剪时机在THINKING发起LLM调用前,所有外部信息确定后统一检查;
  • truncated_blocks计数器:记录累计裁剪了多少块,用于日志展示,让用户知道"历史对话已被裁剪"。

四、改造3:main.py集成预算检查

4.1 预算分配模型

在run_agent入口定义预算常量:

python 复制代码
MODEL_CONTEXT_WINDOW = 8192    # 模型上下文窗口大小,按实际模型调整
OUTPUT_RESERVE = 1024          # 为模型输出预留的token数
memory_budget = MODEL_CONTEXT_WINDOW - OUTPUT_RESERVE
memory = ShortMemory(max_tokens=memory_budget)

总预算8192是保守默认值(覆盖GPT-3.5/DeepSeek-chat等8K模型)。如果你的模型是32K或128K窗口,改这个常量即可。

4.2 THINKING前的预算检查

每轮THINKING调用LLM前,计算外部固定开销,检查总token是否超限:

python 复制代码
if state == AgentTaskState.THINKING:
    # ---- Token预算检查:裁剪最早的消息块以确保不超上下文窗口 ----
    # 外部固定开销 = system prompt + tools schema + 请求前缀 + 输出预留
    external_reserve = (
        count_text(SYSTEM_PROMPT) + 4   # system message固定开销4 tokens
        + count_tools_schema(tools_schema)
        + 2                              # 请求前缀开销
        + OUTPUT_RESERVE                 # 输出预留
    )
    total_estimate = memory.token_count() + external_reserve

    if total_estimate > MODEL_CONTEXT_WINDOW:
        print(f"【上下文管理】当前约{total_estimate} tokens,超出窗口{MODEL_CONTEXT_WINDOW},裁剪最早对话...")
        memory.truncate(reserve_tokens=external_reserve)
        print(f"【上下文管理】已裁剪{memory.truncated_blocks}个消息块,剩余约{memory.token_count()} tokens")

    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    messages.extend(memory.get_messages())
    # ...后续流式调用逻辑不变...

为什么用reserve_tokens参数传给memory.truncate?因为memory的max_tokens是messages部分的预算,但外部开销(system+tools+输出预留)也要占总窗口,所以messages实际可用预算 = max_tokens - external_reserve。memory.truncate内部会计算这个有效预算,然后循环删块直到messages token数在预算内。

4.3 为什么不在add时自动裁剪?

你可能会问:为什么不在add_user/add_assistant/add_tool_result里自动检查裁剪?原因有二:

  1. 添加消息时外部开销还不确定 。tools_schema是在run_agent入口构造的(tools_schema = [t.to_openai_tool_schema() for t in tool_list]),add方法不感知它;
  2. 裁剪时机有语义。裁剪是"要调LLM了,先确保请求合法",不是"加了消息就可能超"。加消息和发起LLM调用之间可能有多次添加(比如TOOL_EXECUTING连续加多条tool结果),不应该中间裁剪。

这和Go里GC的触发时机类似------不是每次分配内存都GC,而是到达安全点(分配后、函数返回前)才检查。

五、验证:配对完整性测试

滑动窗口最关键的不是"能删消息",而是"删完之后消息序列仍然是OpenAI协议合法的"。写一个验证脚本:

python 复制代码
from agent.memory import ShortMemory
from agent.token_counter import count_messages

def test_pairing_integrity():
    """验证裁剪后tool_calls↔tool_result配对完整性"""
    m = ShortMemory(max_tokens=50)
    m.add_user('第一轮对话')
    m.add_assistant('好的', None)
    m.add_user('第二轮:帮我读文件')
    tc = [{'id': 'call_a', 'type': 'function', 'function': {'name': 'read_file', 'arguments': '{"path":"test.py"}'}}]
    m.add_assistant('我来读文件', tc)
    m.add_tool_result('call_a', 'file content here')
    m.add_user('第三轮')
    tc2 = [{'id': 'call_b', 'type': 'function', 'function': {'name': 'calculator', 'arguments': '{"expr":"2+3"}'}}]
    m.add_assistant('计算', tc2)
    m.add_tool_result('call_b', '5')

    print(f'裁剪前消息数: {len(m.history)}')
    print(f'裁剪前roles: {[msg["role"] for msg in m.history]}')

    m.truncate(reserve_tokens=0)

    print(f'裁剪后消息数: {len(m.history)}')
    print(f'裁剪后roles: {[msg["role"] for msg in m.history]}')

    # 验证:没有悬空的tool消息
    for i, msg in enumerate(m.history):
        if msg['role'] == 'tool':
            tc_id = msg['tool_call_id']
            found = False
            for j in range(i-1, -1, -1):
                if m.history[j]['role'] == 'assistant' and m.history[j].get('tool_calls'):
                    if tc_id in {tc['id'] for tc in m.history[j]['tool_calls']}:
                        found = True
                        break
                elif m.history[j]['role'] in ('user', 'assistant') and not m.history[j].get('tool_calls'):
                    break
            assert found, f'配对断裂!tool消息在index={i}没有对应的assistant(tool_calls)'

    print('配对完整性:OK')

test_pairing_integrity()

核心验证逻辑:遍历裁剪后的消息序列,对每条tool消息,向前找最近的带tool_calls的assistant消息,确认它的tool_call_id在那个assistant的tool_calls数组里。如果某条tool消息找不到对应的assistant,就是配对断裂,assert失败。

六、运行效果

配置8K窗口跑一个多轮对话:

复制代码
=== 第1轮 THINKING ===
用户需要分析base_tool.py文件,我先用bash统计行数,再用read_file读取...
【工具调用意图】bash
【工具调用】bash({"command": "wc -l tools/base_tool.py"})
【工具返回】[退出码] 0\n[执行目录] /.../native-agent-demo\n[stdout]\n56 tools/base_tool.py
【工具调用意图】read_file
【工具调用】read_file({"path": "tools/base_tool.py", "offset": 0, "limit": 56})
【工具返回】[文件: tools/base_tool.py] 共56行...
=== 第2轮 THINKING ===
现在我需要读取execute方法和to_openai_tool_schema方法的内容...
【工具调用意图】calculator
【工具调用】calculator({"expr": "(18+20)*4/2"})
【工具返回】计算结果: (18+20)*4/2 = 76.0
=== 第3轮 THINKING ===
根据工具返回结果...
【校验】正在Judge回答完整性...
【校验通过】回答完整

当对话继续多轮后(比如持续对话10+轮,读了多个大文件),触发裁剪:

复制代码
【上下文管理】当前约8437 tokens,超出窗口8192,裁剪最早对话...
【上下文管理】已裁剪2个消息块,剩余约6123 tokens
=== 第8轮 THINKING ===
...

裁剪后Agent继续正常工作------最早的对话块被移除,近期上下文保留。在8K窗口下,通常能保留5-8轮完整对话(具体取决于每轮tool返回内容的长度)。

七、小结

本阶段做的事情:

  1. 新增token_counter.py:用tiktoken精确计算文本/消息/tool_call/tools schema/完整请求的token数,遵循OpenAI官方计数规则;
  2. 改造memory.py:ShortMemory增加token感知(token_count/truncate/truncated_blocks),核心算法是_pop_oldest_block按消息块删除,严格保护tool_calls↔tool_result配对完整性;
  3. 改造main.py:每轮THINKING前计算总token预算(system+tools+messages+输出预留),超窗口自动触发裁剪并打印日志;
  4. 更新requirements.txt:新增tiktoken依赖。

关键设计原则回顾:

  • 展示层和数据层分离:日志打印说"裁剪了N个块"是给人看的,memory里history的完整性是给LLM用的,两者独立;
  • 确定性优先:滑动窗口是纯算法(不调LLM),可靠可预测,是所有更高级策略(摘要压缩/RAG)的基础;
  • 外部预算reserve_tokens模式:memory只管理messages的token,system prompt/tools schema/输出预留由调用方计算后通过reserve_tokens传入,保持memory职责单一;
  • 裁剪时机在"发请求前":不在add时自动裁剪,在THINKING状态即将调LLM、所有外部信息确定后统一检查。
相关推荐
我怎么又饿了呀1 小时前
DataWhale—量化金融(task8 最大回撤 和 仓位管理)
python·金融·量化
AskHarries1 小时前
CDN 要不要上
后端
Black_Rock_br2 小时前
打通 PyTorch Monarch 与 ROCm:单 Controller 架构的异构算力实战
人工智能·pytorch·python·开源
其实防守也摸鱼2 小时前
Kimi K3深度测评:长文本之外的真实力
运维·开发语言·网络·人工智能·python·学习·安全
weixin_BYSJ19872 小时前
springboot3家政平台小程序--附源码00904
java·javascript·spring boot·python·django·flask·php
凤山老林2 小时前
SpringBoot 3 启用 spring.factories 后如何升级替换原有的 spring.factories ?
spring boot·后端·spring
风痕天际2 小时前
Pytorch开发教程1——CUDA安装
人工智能·pytorch·python
梦幻通灵2 小时前
Java中finally失效的几种情况【持续更新】
java·开发语言
月落归舟2 小时前
SpringMVC 多种响应返回方式
java·开发语言