Python 智能体开发实战 07:让它查课表它张口就编?给 Agent 接上真正能动手的工具

上周出了一次挺丢人的事故。一个学生问助教小E:"我明天的课是几点?"小E秒回:"您的写作课在周三下午三点,3 号教室。"学生第二天下午三点到了 3 号教室------那天根本没排课,写作课是周六上午的。

另一个学生问"accord 这个词在你们词表里是几级",小E答得更有底气:"C1 级,考研核心词,建议精读。"实际上它在词表里是 B2,考研大纲里压根没有它。

学生白跑一趟还是小事。带队老师看了聊天记录问我:这模型是不是坏了?

模型没坏,它甚至表现得很"称职"------生成一句像模像样的回答本来就是它唯一会做的事。它没有我们的课表,没有我们的词表,你却要求它回答里面的问题,它只能拿训练时见过的模式补全一个"最像答案的文本"。前六篇我们一直在打磨它"怎么说",这一篇要解决的是更根本的问题:让它闭嘴去查。

一、先扫盲:工具调用到底是谁在干活

市面上太多文章把工具调用写成"模型自己去查了课表",这是最大的误导。真实流程是四步,一步都绕不开人写的代码:

  1. 你的程序把问题连同工具清单一起发给模型;
  2. 模型不执行任何东西,它只回一行结构化意图:"我想调 get_schedule,参数是 student_id=S1001, date=2026-07-25";
  3. 你的程序解析这行意图,去真的执行,把结果包成一条 ToolMessage 追加进消息列表;
  4. 再问一轮模型,它看着结果组织人话。

所以准确的说法是:**模型是动嘴的,你的程序是动手的。**模型从头到尾没碰过你的数据库,它只是"点菜",炒菜、端菜全是你的代码。想通这一点,后面所有的工程决策都顺理成章------工具的可靠性、安全性、报错方式,全都取决于你的代码怎么写,跟模型关系不大。

工具 schema 的三要素

模型是"读着说明书挑工具"的。说明书就是每个工具的 schema,一共三样东西:

  • name :工具名。起名要像动词:lookup_word、get_schedule。别起 tool_1。
  • description:干什么用、什么时候该用。这是模型决定"调不调"的唯一依据。
  • 参数说明:每个参数是什么、什么格式。这是模型决定"怎么传"的唯一依据。

我在 description 里会写两句"当学生问......时调用本工具"和"不要凭印象回答,一律以本工具返回为准"。听起来啰嗦,但模型真的在读这些话。同样,参数说明里我写死了"必须是 YYYY-MM-DD 格式,例如 2026-07-25"------不写这句,模型会把你最不想看到的字符串原样传进来。描述写得含糊,模型就不会用、或者乱用,这不是玄学,是说明书问题。

tool_calls → ToolMessage → 再问一轮

模型想调工具时,返回的消息里会带一个 tool_calls 字段,每个调用有三样:工具名、参数、还有一个 id。你的程序执行完,要构造一条 ToolMessage(content=结果, tool_call_id=那个id) 追加进消息列表,再 invoke 一次。id 是用来对账的------哪条结果回应哪个调用,接口靠它核对,对不上直接报错。

这个"问一次、执行、回传、再问"的循环就是 Agent 的心脏。本篇我们把它手写一遍,再用 LangGraph 的预置实现跑一遍。

二、工程目录

ruby 复制代码
07-工具调用/
├── src/
│   ├── settings.py   # 复用第 2 篇
│   ├── llm.py        # 复用第 2 篇
│   ├── tools.py      # 三个 @tool 本地工具 + 默认数据
│   ├── registry.py   # TOOLS 清单 + 查找 + schema 快照
│   ├── loop_guard.py # 步数上限 + 重复调用检测
│   ├── agent.py      # run_manual(手写循环)/ run_react(预置 ReAct 图)
│   └── fakes.py      # 记录型脚本假模型
├── tests/            # 38 个离线用例
├── demo.py
├── pytest.ini / requirements.txt / README.md / .env.example / .gitignore

三个工具都不联网,数据来自构造时传入的字典。真实项目里换成查数据库或调教务系统接口,函数签名一个字不用改。

三、tools.py:工具是给程序用的,报错要说人话

先看最核心的"查课表"。三个工具我用工厂函数造而不是直接写在模块顶层,原因在注释里:

python 复制代码
def make_get_schedule(
    schedules: Mapping[str, Mapping[str, list[dict[str, str]]]],
) -> BaseTool:
    """造一个"查课表"工具,数据来自传入的字典。"""

    @tool(parse_docstring=True)
    def get_schedule(student_id: str, date: str) -> str:
        """查询某个学生在指定日期的课表。

        当学生问「我明天/某天有课吗」「几点上课」「在哪间教室」「哪个老师」时调用本工具。
        时间、教室、老师一律以本工具返回为准,不要凭印象推断。

        Args:
            student_id: 学号,大写 S 开头加四位数字,例如 S1001。
            date: 要查的日期,必须是 YYYY-MM-DD 格式,例如 2026-07-25。
        """
        sid = (student_id or "").strip()
        day = (date or "").strip()

        # 两个参数都可能是模型"自作主张"填出来的,逐个挡回去
        if not sid:
            return "没有拿到学号。请让学生提供学号(S 开头加四位数字,例如 S1001)。"
        if not _DATE_RE.match(day):
            return (
                f"日期「{day or '(空)'}」格式不对,需要 YYYY-MM-DD,例如 2026-07-25。"
                "如果学生说的是「明天」「下周三」,请先换算成具体日期再调用。"
            )
        try:
            weekday = _weekday_of(day)
        except ValueError:
            return f"「{day}」不是一个真实存在的日期,请核对年月日后重新查询。"

        own = schedules.get(sid)
        if own is None:
            return _not_found_student(sid)

        lessons = own.get(day) or []
        if not lessons:
            return f"{sid} 在 {day}({weekday})没有排课,可以安排自习或预约补课。"

        lines = [f"{sid} · {day}({weekday})共 {len(lessons)} 节课"]
        for i, lesson in enumerate(lessons, start=1):
            lines.append(
                f"{i}. {lesson.get('time', '')} {lesson.get('course', '')}"
                f" · {lesson.get('teacher', '')} 在 {lesson.get('room', '')}"
            )
        return "\n".join(lines)

    return get_schedule

三个刻意的决定,一个比一个重要:

**第一,用工厂函数把数据"注入"进去。**测试里塞两三条假数据就能断言命中与未命中,上线时换成从数据库读的那份------工具的逻辑和数据是分离的,这才叫工程对象,不叫脚本。

**第二,docstring 用了 @tool(parse_docstring=True) 而不是裸 @tool。**这是我本篇踩得最深的一个坑,单列在踩坑清单第 1 条:langchain-core 1.x 里这个开关默认是关的,Args: 段不会被解析进参数 schema,模型拿到的就只有一个光秃秃的 {"word": {"type": "string"}}------参数说明整段丢失。后面 registry.py 里有测试专门盯这件事。

**第三,参数不合法时返回友好错误文本,绝不抛异常。**这是工具调用里最反直觉又最要命的工程细节:工具是在循环里被调用的,一个 ValueError 会顺着循环一路炸到最外层,学生看到的是"系统错误",而不是"日期格式不对"。更妙的是,一段说清楚错在哪、该怎么改的文本喂回给模型后,模型有机会当场自救------它会把"明天"换算成日期再调一次。抛异常则什么都救不了。报错文本本身就是给模型的提示词,这个观念转过来,工具就活了。

另外注意返回值全是字符串,还带着编号和换行------这段文本会被原样塞进 ToolMessage,既给模型看,也给人看(日志里它是最常被翻的行)。

lookup_word 和 get_learning_record 结构完全一样,不再贴全文,只看一眼查询未命中的话术:

python 复制代码
def _not_found_word(word: str) -> str:
    """词表没命中时的统一话术。"""
    return (
        f"词表里暂时没有「{word}」这个单词。"
        "如果你确认拼写没错,可以先按通用释义讲,或让教研同事把它补进分级词表。"
    )

**"没查到"必须是一个明确的、可读的答案,而不是 None,更不是编一个。**本篇开头的"周三下午三点",本质就是没有工具兜底时模型替数据发了言。

四、registry.py:工具清单只留一个真相

python 复制代码
TOOLS: list[BaseTool] = build_tools()

def get_tool(name: str, tools: Sequence[BaseTool] | None = None) -> BaseTool:
    pool = list(tools) if tools is not None else TOOLS
    for item in pool:
        if item.name == name:
            return item
    raise KeyError(f"工具 {name} 不存在,当前可用:{[t.name for t in pool]}")

def schema_snapshot(tools: Sequence[BaseTool] | None = None) -> list[dict[str, Any]]:
    """导出工具 schema 快照:name / description / parameters 三件套。"""
    pool = list(tools) if tools is not None else TOOLS
    return [
        {
            "name": item.name,
            "description": item.description or "",
            "parameters": item.args_schema.model_json_schema()
            if item.args_schema
            else {},
        }
        for item in pool
    ]

get_tool 对不存在的名字直接抛 KeyError,不静默兜底------名字写错是开发期就该炸的 bug,拖到线上就是模型拿着错误结果推理三轮。schema_snapshot 是给测试用的:哪天有人把 docstring 里的 Args: 段删了,或者忘了 parse_docstring=True,参数说明就从 schema 里消失,模型随即开始乱填参数------这种 bug 在代码 review 里根本看不出来,用例一跑就红。

五、loop_guard.py:凡是循环,先回答"它一定会停吗"

工具调用把"一次问答"变成了"一个循环",循环必须配护栏。我遇到过一次真实的打转:学生说"下周三",模型不知道今天几号,连着三轮调 get_schedule(student_id="S1001", date="下周三"),每次拿到同一句"日期格式不对",然后原样重试------三轮下来烧了三倍 token,学生看到同一句错误连发三遍。

python 复制代码
DEFAULT_MAX_STEPS = 4  # 三个工具,最多三轮查证 + 一轮组织语言

def call_key(name: str, args: Mapping[str, Any]) -> str:
    """把"工具名 + 参数"压成一个稳定的字符串,用作去重键。"""
    return json.dumps({"name": name, "args": dict(args or {})},
                      sort_keys=True, ensure_ascii=False)

class LoopGuard:
    def next_step(self) -> int:
        """开始新的一步:步数 +1,超限就抛。"""
        if self.steps >= self.max_steps:
            raise StepLimitExceeded(f"已达步数上限 {self.max_steps},强制收工")
        self.steps += 1
        return self.steps

    def is_repeated(self, name: str, args: Mapping[str, Any]) -> bool:
        return call_key(name, args) in self._seen

    def repeat_notice(self, name: str, args: Mapping[str, Any]) -> str:
        previous = self._seen.get(call_key(name, args), "(上次结果已丢失)")
        return REPEAT_NOTICE.format(name=name, previous=previous)

两个细节。json.dumps(sort_keys=True) 是为了让"参数一样但键序不同"的两次调用判成重复------模型传参的键序真的不稳定。重复的处置方式是"回提示"而不是"抛异常":把上次结果再贴一遍,告诉它"要么换参数,要么直接回答",模型有机会自救;只有步数耗尽才由上层强制收工。护栏是来劝架的,不是来掀桌的。

六、agent.py 之一:run_manual,手写那三十行

python 复制代码
def run_manual(
    question: str,
    llm: BaseChatModel,
    tools: Sequence[BaseTool] | None = None,
    *,
    max_steps: int = DEFAULT_MAX_STEPS,
    system: str = SYSTEM_PROMPT,
) -> AgentResult:
    pool = list(tools) if tools is not None else TOOLS
    by_name = {tool.name: tool for tool in pool}

    # bind_tools:把工具 schema 挂在模型上,模型才知道自己有哪些工具可用
    llm_with_tools = llm.bind_tools(pool)

    messages: list[BaseMessage] = [
        SystemMessage(content=system),
        HumanMessage(content=question),
    ]
    guard = LoopGuard(max_steps)
    steps: list[StepRecord] = []

    while True:
        # 先过步数护栏:每一步都是真金白银的模型调用,不能无限
        try:
            index = guard.next_step()
        except StepLimitExceeded:
            return AgentResult(question, FALLBACK_ANSWER, steps, STOP_MAX_STEPS)

        ai = llm_with_tools.invoke(messages)
        messages.append(ai)

        calls = list(getattr(ai, "tool_calls", None) or [])
        if not calls:
            # 模型不再要工具 → 它这次给的就是最终回答
            return AgentResult(question, _text_of(ai), steps, STOP_FINAL)

        record = StepRecord(index=index, tool_calls=[dict(item) for item in calls])
        stopped: str | None = None

        for position, item in enumerate(calls):
            name = str(item.get("name") or "")
            args = dict(item.get("args") or {})

            if guard.is_repeated(name, args):
                # 重复调用:不执行,回一段提示让模型知道它打转了
                observation = guard.repeat_notice(name, args)
                stopped = STOP_REPEAT
            else:
                observation = _run_tool(by_name, name, args)
                guard.remember(name, args, observation)

            record.observations.append(observation)
            messages.append(
                ToolMessage(
                    content=observation,
                    tool_call_id=str(item.get("id") or f"call_{index}_{position}"),
                )
            )

        steps.append(record)
        if stopped == STOP_REPEAT:
            return AgentResult(question, REPEAT_ANSWER, steps, STOP_REPEAT)

对照第一节的四步看:bind_tools 是"给模型说明书",invoke 后读 tool_calls 是"听它点菜",_run_tool 是"去炒菜",ToolMessage 追加后再进下一轮是"把菜端回去让它摆盘"。循环只有两个出口:模型不要工具了(正常),或者护栏叫停(兜底)。

_run_tool 里还包了一层 try/except,这是第二道防线------工具函数已经承诺"不抛异常",但工具迟早换成查数据库、调 HTTP 的版本,那时它不一定守得住承诺。兜住它,一次工具崩溃只影响这一步,不会连累整轮对话。

每一步我都记进 StepRecord:模型想调什么(intent)、工具回了什么(observations)。这些档案是 demo 的输出,也是排查线上问题的第一现场。

七、agent.py 之二:run_react,预置图替你省了什么

python 复制代码
def run_react(
    question: str,
    llm: BaseChatModel,
    tools: Sequence[BaseTool] | None = None,
    *,
    system: str = SYSTEM_PROMPT,
    recursion_limit: int = RECURSION_LIMIT,
) -> AgentResult:
    pool = list(tools) if tools is not None else TOOLS

    agent = create_react_agent(llm, pool, prompt=system)
    result = agent.invoke(
        {"messages": [HumanMessage(content=question)]},
        {"recursion_limit": recursion_limit},
    )

    messages = list(result["messages"])
    answer = _text_of(messages[-1]) if messages else ""
    return AgentResult(question, answer, _records_from(messages), STOP_FINAL)

LangGraph 的 create_react_agent 把 run_manual 那三十行全部接管:bind_tools、tool_calls 解析、ToolMessage 组装、循环退出判断,都是预置图的内置件。四行调用,同一条脚本喂进去,答案和手写版一字不差(测试里有专门的对照用例)。

**那什么时候还得手写?**当你需要:自定义停止条件("涉及退费的工具必须人工确认后才执行")、逐步审计落库、按业务动态换工具清单、或者像本篇这样在循环里插自己的护栏逻辑。循环不在你的代码里,你就看不见它------我第一次用预置图时模型在里头转了十几轮才发现,就是因为循环藏在框架里。先手写一遍再决定用不用现成的,你至少得知道轮子里有什么。

顺带交代一个环境事实:装 langgraph-prebuilt 1.x 时,create_react_agent 会报一条 "moved to langchain.agents" 的弃用警告,v2 才移除。本系列没有装 langchain 主包,照旧用 langgraph 这条通路即可,别被警告吓到去乱改依赖。

八、跑起来:真实输出

python demo.py(离线:工具是真的执行了,只有"模型决定调谁"由脚本预置)。问题二"我明天(2026-07-25)有什么课?顺便告诉我学到哪了"的完整过程:

css 复制代码
── run_manual(手写循环)──
问:我明天(2026-07-25)有什么课?顺便告诉我我现在学到哪了。
答:7 月 25 日(周六)你有两节课:09:00-10:30 雅思写作精讲(王老师,3 号教室),19:00-20:00 口语陪练(外教 Anna,线上)。另外你已学 42/80 课时,进度 52%。
停止原因:final
  第 1 步 · 意图 get_schedule(student_id='S1001',date='2026-07-25') + get_learning_record(student_id='S1001')
      ← S1001 · 2026-07-25(周六)共 2 节课
      ← 1. 09:00-10:30 雅思写作精讲 · 王老师 在 3 号教室
      ← 2. 19:00-20:00 口语陪练 · 外教 Anna 在 线上
      ← S1001 · 张一鸣(雅思冲刺班)
      ← 已学 42 / 80 课时,进度 52%
      ← 最近一次测评:2026-07-20,写作 6.0 / 口语 6.5
      ← 薄弱项:听力地图题、写作 Task2 开头段

注意"第 1 步"里模型一口气点了两个工具------同一轮里的并行 tool_calls,我的循环是逐个执行、逐条回传。所有时间、教室、百分比全部来自 tools.py 里的字典,模型一个字没编。

再看问题三,也就是事故现场的重演:

ini 复制代码
── run_manual(手写循环)──
问:下周三我有课吗?(学号 S1001)
答:我没法确认「下周三」具体是几号,请你告诉我日期(比如 2026-07-29),我再帮你查。
停止原因:final
  第 1 步 · 意图 get_schedule(student_id='S1001',date='下周三')
      ← 日期「下周三」格式不对,需要 YYYY-MM-DD,例如 2026-07-25。
        如果学生说的是「明天」「下周三」,请先换算成具体日期再调用。

模型把"下周三"原样传了进来(真模型就是这么干的),工具回了格式提示而不是异常,模型读着提示老实承认了"我没法确认"------没有再编。

九、测试:38 个用例,全程离线

../_venv/bin/python -m pytest --capture=no -p no:cacheprovider -q:

arduino 复制代码
tests/test_agent_manual.py::test_single_round_trip PASSED
tests/test_agent_manual.py::test_two_rounds_are_both_recorded PASSED
tests/test_agent_manual.py::test_parallel_tool_calls_in_one_step PASSED
tests/test_agent_manual.py::test_tool_result_reaches_the_next_request PASSED
tests/test_agent_manual.py::test_tool_schema_is_bound_to_the_model PASSED
tests/test_agent_manual.py::test_max_steps_stops_the_loop PASSED
tests/test_agent_manual.py::test_repeated_call_is_blocked PASSED
tests/test_agent_manual.py::test_unknown_tool_name_does_not_crash PASSED
tests/test_agent_manual.py::test_tool_exception_is_contained PASSED
tests/test_agent_manual.py::test_model_may_answer_without_tools PASSED
tests/test_agent_react.py::test_react_runs_a_single_tool PASSED
tests/test_agent_react.py::test_react_really_executed_the_tool PASSED
tests/test_agent_react.py::test_react_produces_tool_messages PASSED
tests/test_agent_react.py::test_react_matches_manual_on_the_same_script PASSED
tests/test_loop_guard.py::test_step_limit_raises PASSED
tests/test_loop_guard.py::test_first_call_is_never_repeated PASSED
tests/test_loop_guard.py::test_same_args_twice_is_repeated PASSED
tests/test_loop_guard.py::test_different_args_are_not_repeated PASSED
tests/test_loop_guard.py::test_call_key_ignores_argument_order PASSED
tests/test_loop_guard.py::test_repeat_notice_carries_previous_result PASSED
tests/test_registry.py::test_registry_has_three_tools PASSED
tests/test_registry.py::test_get_tool_hit PASSED
tests/test_registry.py::test_get_tool_unknown_raises PASSED
tests/test_registry.py::test_snapshot_has_three_fields PASSED
tests/test_registry.py::test_every_schema_has_parameters PASSED
tests/test_registry.py::test_every_parameter_has_a_description PASSED
tests/test_registry.py::test_required_params_match_expectation PASSED
tests/test_tools.py::test_three_tools_are_built PASSED
tests/test_tools.py::test_lookup_word_hit PASSED
tests/test_tools.py::test_lookup_word_miss_is_friendly PASSED
tests/test_tools.py::test_get_schedule_hit PASSED
tests/test_tools.py::test_get_schedule_unknown_student PASSED
tests/test_tools.py::test_get_learning_record_hit PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[empty-word] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[date-says-tomorrow] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[date-impossible] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[no-class-that-day] PASSED
tests/test_tools.py::test_tools_use_the_data_they_were_built_with PASSED

38 passed, 4 warnings in 0.65s

38 个全绿,不联网、不要 Key(那 4 条 warning 就是第七节说的 create_react_agent 弃用提示)。

这组测试里我最想让你记住的是 test_tool_result_reaches_the_next_request:它用一个会存档的假模型,断言工具返回的"B2 级"和例句原文真的出现在了模型第二次收到的消息列表里 。工具跑完了不等于模型看到了------如果 ToolMessage 忘了追加,模型下一轮照样一问三不知,然后凭印象补一个答案,事故就回来了。test_react_matches_manual_on_the_same_script 同样值得看:同一条脚本喂两条实现,答案和观察记录必须一致,不一致说明其中一条漏了东西。

十、踩坑清单

  1. 裸用 @tool,docstring 里的 Args: 段被静默丢弃 。langchain-core 1.x 的 parse_docstring 默认是 False,schema 里只剩参数名和类型,参数说明整段消失,模型开始乱填参数------而代码不报任何错。必须写 @tool(parse_docstring=True),并且像本篇那样用测试盯住"每个参数都有 description"。
  2. 工具里抛异常,整个循环跟着炸 。一次拼错的日期就把整轮对话变成"系统错误"。工具内部一律把非法参数转成友好文本返回;agent 的 _run_tool 里再兜一层 try/except 当第二道防线。
  3. 模型把"明天""下周三"原样传给 date 参数。约束写不进类型注解,只能写进参数说明("必须是 YYYY-MM-DD,例如 2026-07-25")和工具的报错文本里,两处都要有,缺一处它就重演。
  4. 自带的 FakeListChatModel 跑不了工具通路。它没实现 bind_tools,langchain-core 1.x 里基类那个直接抛 NotImplementedError。我照第 4 篇的做法自己写了个二十行的脚本假模型,顺带存档每次收到的消息------没有存档,"结果有没有回到模型手里"就只能靠猜。
  5. bind_tools 返回了副本,脚本下标被打回 0 。假模型的 bind_tools 如果 return self.bind(...) 之类返回新对象,内部计数器归零,每一步都吐脚本第一条,多轮用例永远走不到第二步。要就地改标记并 return self。
  6. 没有护栏的循环是最贵的 bug。步数上限和重复调用检测必须一开始就写进循环,而不是等账单教你。模型卡住时不报错,只是反复用同样的参数调同一个工具。
  7. ToolMessage 的 tool_call_id 对不上会直接被接口拒收 。并行多个 tool_calls 时要逐个对应着回;模型偶尔不给 id,我用 call_{步数}_{序号} 兜底生成,保证消息列表永远合法。

十一、本篇收尾

回头看开头那两句话术:"周三下午三点"和"C1 级"。它们的共同点不是错,而是模型根本无权回答。这一篇之后,小E面对等级、时间、成绩这三类问题,会先说"我想调哪个工具",然后拿到你的数据说话;查不到就承认查不到,顺便告诉学生缺什么信息。回答的每个数字都能在数据里找到出处------这才配叫助教。

下一篇解决另一个上线必炸的问题:**工具要调远端接口了,超时、限流、网络抖动怎么办?**我们给 Agent 装上重试、超时和降级------让它坏得慢一点、坏得体面一点。


最后一句:模型从不缺想象力,缺的是边界------工具调用的全部意义,就是把"它以为的答案"换成"你数据里的答案"。

相关推荐
Wang's Blog1 小时前
Java 项目实战:交友项目 - 即时通信总结:全链路复盘、可复用清单与自检
java·python·交友
沛东的认知和实践1 小时前
当 RAG 开始“要求找全”:一次 Agentic RAG 边界探索与 LLM Wiki 转向
llm·agent
2601_962885721 小时前
按主板/科创/创业/北交分板扫描选股
python
威哥爱编程1 小时前
Python 智能体开发实战 08:上线第一天就挂?给 Agent 装上重试、超时和降级三条保险丝
python·agent
Dmz丶1 小时前
前端转 AI 100 天 Day 35:结构化输出——让 LLM 稳定返回 JSON
python
在世修行1 小时前
智学文枢:数据管理服务
python·审计日志·pyside·数据清理·孤儿文件
日日大王学编程1 小时前
DeepSeekHarness开源Agent底座详解
python·开源·deep learning
Eider19982 小时前
python基础语法之并发基础练习
开发语言·python
长和信泰光伏储能2 小时前
探索绿色能源:光伏发电的渠道与应用
python·能源