瀏覽代碼

Merge pull request #911 from datawhalechina/codex/recover-pr-893-squashed

[毕业设计] stowageagent - 集装箱船配载智能体
Sizhou Chen 5 天之前
父節點
當前提交
8d2a494e62

+ 14 - 0
Co-creation-projects/dandan693-StowageAgent/.env.example

@@ -0,0 +1,14 @@
+# 把这个文件复制一份,改名为 .env,然后填上你自己的信息。
+# ⚠️ .env 里有你的密钥,绝对不要提交到 GitHub(.gitignore 已经把它挡住了)。
+
+# 模型名,按你的服务商给的名字填
+LLM_MODEL_ID=deepseek-chat
+
+# 你的 API 密钥
+LLM_API_KEY=sk-在这里填你自己的密钥
+
+# 服务地址(必须是兼容 OpenAI 接口的地址)
+LLM_BASE_URL=https://api.deepseek.com/v1
+
+# 单次请求超时秒数,可选
+LLM_TIMEOUT=60

+ 24 - 0
Co-creation-projects/dandan693-StowageAgent/.gitignore

@@ -0,0 +1,24 @@
+# 密钥文件,绝对不能上传
+.env
+.env.local
+
+# Python 缓存
+__pycache__/
+*.py[cod]
+.ipynb_checkpoints/
+
+# 虚拟环境
+.venv/
+venv/
+env/
+
+# 运行时产物
+# 说明:outputs/评估报告.json 故意不忽略 —— 它是评估证据,跟着仓库一起提交
+outputs/长期笔记.md
+outputs/_*.txt
+*.log
+
+# 编辑器
+.idea/
+.vscode/
+.DS_Store

+ 21 - 0
Co-creation-projects/dandan693-StowageAgent/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 dandan693
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 478 - 0
Co-creation-projects/dandan693-StowageAgent/README.md

@@ -0,0 +1,478 @@
+# StowageAgent - 集装箱船配载智能体
+
+[![Python](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/)
+[![License](https://img.shields.io/badge/License-MIT-green)](./LICENSE)
+[![Tests](https://img.shields.io/badge/工具自检-15%2F15-success)](./outputs/评估报告.json)
+[![Eval](https://img.shields.io/badge/端到端评估-12%2F12-success)](./outputs/评估报告.json)
+[![框架](https://img.shields.io/badge/Agent框架-零依赖-orange)](#-技术栈)
+
+> 一个会查配载规则、会算贝位坐标的集装箱船配载助手。
+> 纯 Python 手写 ReAct 循环,**不依赖任何 Agent 框架,装 4 个包就能跑。**
+
+---
+
+## 📝 项目简介
+
+StowageAgent 是一个面向集装箱船配载场景的智能体。它要解决的是配载作业里最重复、最容易出错的两件事:
+
+1. **查规则** —— 40 尺箱该写大贝号还是小贝号?层号 84 在甲板还是舱内?图上出现一个星号算不算箱位?
+   这些规则散在经验里、散在旧文件里,每次都要翻。
+2. **算坐标** —— 把贝位、排位、层号拼成六位坐标,还要校验有没有算错、有没有重复。
+   纯手工做,眼睛累、容易错。
+
+本项目把一个智能体接进这两件事,它能够:
+
+- 规则问题 → **先查知识库再回答**(RAG),资料里没有就老实说没有,不瞎编。
+- 计算问题 → **调用工具算**,不让大模型心算(它算数真的不行)。
+- 算完 → **自动校验** 一批坐标,把错的地方挑出来。
+- 全过程 → 把「思考 → 行动 → 观察」每一步打印出来,看得见它为什么这么答。
+
+本项目为**纯 Python 实现**:命令行入口 `main.py` + 教学版 Notebook `main.ipynb`,没有前端页面,
+也不依赖任何 Agent 框架 —— 只用 `openai` 一个 SDK 手写 ReAct 循环。
+
+### 模块划分:7 个文件各管什么
+
+| 模块 | 职责 |
+|---|---|
+| `src/llm.py` | 大模型接入 —— 怎么跟模型说话 |
+| `src/tools.py` | 工具系统 —— 4 个真实业务工具 + 工具注册表 |
+| `src/rag.py` | 知识库检索 —— 翻资料(tfidf / vector 双模式) |
+| `src/memory.py` | 记忆 —— 短期对话 + 长期笔记 |
+| `src/context.py` | 上下文工程 —— 拼提示词、裁长度 |
+| `src/agent.py` | Agent 主体 —— 手写 ReAct 循环(思考 → 行动 → 观察) |
+| `src/evaluate.py` | 性能评估 —— 给自己的 Agent 打分 |
+
+> 本项目**故意不依赖任何 Agent 框架**,只用 `openai` 一个 SDK 手写,
+> 方便你看清一个智能体内部到底在做什么:这 7 个文件读完,骨架就清楚了。
+
+### 整体结构
+
+```mermaid
+flowchart TD
+    U([用户提问]) --> A[ReActAgent<br/>思考 → 行动 → 观察 循环]
+    A -->|决定用哪个工具| T{工具注册表<br/>ToolRegistry}
+    T --> T1[贝位计算器<br/>01+03 → 02]
+    T --> T2[坐标解析器<br/>010682 → 贝/排/层]
+    T --> T3[箱位校验器<br/>批量查错、去重]
+    T --> T4[知识库检索<br/>RAG:tfidf / vector]
+    T1 --> O[观察结果]
+    T2 --> O
+    T3 --> O
+    T4 --> O
+    O --> A
+    A -->|信息够了| F([最终答案<br/>资料里没有就直说])
+
+    M[Memory<br/>短期对话 + 长期笔记] -.-> A
+    C[ContextBuilder<br/>拼提示词、裁长度] -.-> A
+
+    subgraph 评估层
+        E1[工具自检 15 例<br/>离线·确定性]
+        E2[端到端评估 12 例<br/>联网·真实问答]
+    end
+
+    style A fill:#e8f0fe,stroke:#4285f4,color:#111
+    style F fill:#e6f4ea,stroke:#34a853,color:#111
+    style T fill:#fef7e0,stroke:#fbbc04,color:#111
+    style M fill:#fce8e6,stroke:#ea4335,color:#111
+    style C fill:#fce8e6,stroke:#ea4335,color:#111
+    style E1 fill:#f1f3f4,stroke:#9aa0a6,color:#111
+    style E2 fill:#f1f3f4,stroke:#9aa0a6,color:#111
+```
+
+### 适用场景
+
+- **想学 Agent 开发但被框架绕晕的人** —— 这个项目的 ReAct 循环不依赖任何框架,`src/agent.py` 全部 208 行,能一行不跳地读完
+- **想做「文档问答」类应用的人** —— RAG 的四步拆成了四个方法,一步一步看
+- **集装箱配载 / 货运相关从业者** —— 知识库里的规则是真的,可以直接拿去用
+- **想给 Agent 写测试的人** —— 项目里有两层评估:工具层单元测试 + 端到端准确率
+
+---
+
+## ✨ 核心功能
+
+- [x] **贝位计算器**:小贝合成大贝(01+03→02),大贝拆小贝,非法配对会报错。
+- [x] **坐标解析器**:六位坐标 ↔ 贝位 / 排位 / 层号互转,自动判断舱内还是甲板,强制补前导零。
+- [x] **箱位校验器**:批量校验坐标 —— 格式错误、重复、40 尺箱写了奇数贝位,并统计各箱型数量。
+- [x] **知识库检索(RAG)**:两种检索模式 `tfidf`(关键词)和 `vector`(语义向量),可对比效果。
+- [x] **ReAct 循环**:自己决定该用哪个工具、该不该查资料,最多循环 5 轮。
+- [x] **记忆机制**:短期对话记忆(`chat` 模式能记住前面说过的话)+ 长期笔记文件。
+- [x] **两层评估**:工具层(离线、免费、结果确定)+ 端到端(联网、测真实问答能力)。
+- [x] **不会瞎编**:资料里没有的问题,它会回答「没有提到」,项目里有专门的测试用例测这一点。
+
+---
+
+## 📁 项目结构
+
+```
+dandan693-StowageAgent/
+├── main.py                  # 命令行入口:tools / ask / chat / eval
+├── main.ipynb               # 教学版 Notebook:按步骤点下来就跑完
+├── requirements.txt
+├── .env.example             # 复制成 .env 再填密钥
+├── LICENSE                  # MIT
+├── src/                     # ← 核心代码
+│   ├── llm.py               # 大模型接入:跟大模型说话
+│   ├── tools.py             # 工具系统:4 个业务工具 + 工具注册表
+│   ├── rag.py               # 知识库检索(tfidf / vector 双模式)
+│   ├── memory.py            # 短期对话记忆 + 长期笔记
+│   ├── context.py           # 拼提示词、裁上下文长度
+│   ├── agent.py             # 手写 ReAct 循环
+│   └── evaluate.py          # 两层评估
+├── data/
+│   ├── 配载知识库.txt        # 21 段真实配载规则(RAG 的「资料」)
+│   └── 测试用例.json         # 15 条工具自检 + 12 条端到端用例
+└── outputs/
+    ├── 评估报告.json         # 评估结果(跟仓库一起提交,作为证据)
+    └── 长期笔记.md           # Agent 运行中自己记的笔记
+```
+
+---
+
+## 🛠️ 技术栈
+
+- **智能体范式**:**手写 ReAct**,没有使用任何 Agent 框架
+- **大模型调用**:`openai` SDK —— 任何兼容 OpenAI 接口的服务都能用(DeepSeek / 通义 / 智谱 / 本地 vLLM)
+- **检索**:`scikit-learn` 的 TF-IDF(默认);可选 `sentence-transformers` 做语义向量
+- **记忆**:手写实现 —— 短期 messages 列表 + 长期 md 笔记文件
+- **配置**:`python-dotenv` 读 `.env`
+- **评估**:手写两层评估框架,结果落盘成 JSON
+
+**为什么不直接用现成的 Agent 框架?**
+先手写一遍,才知道框架替你干了什么。
+项目里的模块划分(`llm` / `tools` / `memory` / `context` / `agent` / `evaluate`)和主流框架的设计是一一对应的。
+
+---
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10 或以上
+- 一个兼容 OpenAI 接口的大模型 API Key(DeepSeek 有免费额度,注册很快)
+
+### 1. 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+需要联网,会装 `openai` / `python-dotenv` / `scikit-learn` / `numpy` 四个包,一分钟以内。
+
+### 2. 配置 API 密钥
+
+```bash
+# Windows
+copy .env.example .env
+# macOS / Linux
+cp .env.example .env
+```
+
+然后打开 `.env`,填上你自己的信息:
+
+```env
+LLM_MODEL_ID=deepseek-chat
+LLM_API_KEY=sk-你的密钥
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_TIMEOUT=60
+```
+
+> ⚠️ `.env` 已经被 `.gitignore` 挡住了,不会上传到 GitHub。但你自己要记住:
+> **API Key 提交上去,几小时内就会被爬虫扫到盗用。** 这是新手最常见的翻车点。
+
+### 3. 运行项目
+
+在**项目根目录**下执行:
+
+```bash
+# 3.1 看看有哪些工具
+python main.py tools
+
+# 3.2 问一个问题(--show-steps 会打印它的思考过程)
+python main.py ask "贝位 09 和 11 合成的大贝是几号?" --show-steps
+
+# 3.3 只用检索、不调用大模型 —— 想看懂 RAG 原理就跑这个,免费且秒出
+python main.py ask "坐标的前导零丢了会怎样" --no-llm
+
+# 3.4 连续对话(能记住前面说过的话)
+python main.py chat
+
+# 3.5 跑评估
+python main.py eval --offline    # 工具自检,免费,秒出
+python main.py eval --online     # 端到端评估,要调大模型,慢
+python main.py eval --all        # 两层都跑
+```
+
+### 4. 或者用 Notebook 学
+
+```bash
+jupyter lab main.ipynb
+```
+
+`main.ipynb` 是**教学版**:每一步单独一格,从连大模型到跑评估,按顺序点下来就把 7 章串起来了。
+
+### 💡 常见启动问题与注意事项
+
+- **没有 API Key 想先看效果?** 加 `--no-llm` 参数,只用检索、不调用大模型,一分钱不花也能跑通。
+- **提示 `ModuleNotFoundError: No module named 'src'`?** 请确认你在**项目根目录**(能看到 `main.py` 的那一层)执行命令。`main.py` 已经做了路径兜底,从项目根跑最稳。
+- **在 Notebook 里改了 `.env` 不生效?** 环境变量在进程启动时读入,改完 `.env` 需要**重启内核**(或重新跑一遍加载单元格)。命令行则每次运行都会重新读,不受影响。
+- **`pip` 报错(Fatal error in launcher / 找不到 pip)?** 改用 `python -m pip install -r requirements.txt`,这是最通用的写法。
+- **想用语义向量检索?** 加 `--mode vector`,但需要先取消 `requirements.txt` 里 `sentence-transformers` 那行的注释,首次运行还会下载约 400MB 的中文向量模型。**建议先用默认的 `tfidf` 把流程跑通。**
+- **中文输出乱码?** Windows 终端先切一下编码:PowerShell 执行 `$env:PYTHONIOENCODING="utf-8"`,CMD 执行 `chcp 65001`。
+- **调用大模型报 401 / 连接超时?** 检查 `.env` 里 `LLM_BASE_URL` 结尾是否带 `/v1`(不同服务商要求不同),以及 `LLM_MODEL_ID` 是否服务商真实支持的模型名。
+
+---
+
+## 📖 使用示例
+
+### 例 1:复合问题(既要用工具、又要查资料)
+
+```bash
+python main.py ask "贝位 09 和 11 合成的大贝是几号?层号 84 的箱子在甲板上还是船舱里?" --show-steps
+```
+
+实际运行过程(这就是 ReAct 的「思考轨迹」):
+
+```text
+── 第 1 轮 ──
+  💭 思考:用户问贝位 09 和 11 合成的大贝号,这需要贝位计算器;层号 84 属于舱内还是甲板需要查配载规则。
+  🔧 行动:贝位计算器
+  📥 输入:09+11
+  👀 观察:09 + 11 → 大贝 10
+说明:40尺箱写大贝号 10;20尺箱写各自的小贝号 09 / 11
+
+── 第 2 轮 ──
+  💭 思考:大贝已由贝位计算器返回为 10;层号 84 需要查配载规则确认。
+  🔧 行动:知识库检索
+  📥 输入:层号84是舱内还是甲板
+  👀 观察:【资料1|相关度 0.396】
+层号决定箱子在船的什么位置:层号 02 到 08 表示箱子装在船舱内部(舱内);层号 82 及以上(常见 82、84、86、88)表示箱子装在甲板上(舱面)……
+
+── 第 3 轮 ──
+  💭 思考:贝位 09 和 11 合成的大贝已由贝位计算器返回为 10;层号 84 根据资料1属于甲板(舱面)。
+  ✅ 最终答案:贝位 09 和 11 合成的大贝是 10 号;层号 84 的箱子在甲板上(舱面)。
+```
+
+注意它做了两件普通聊天机器人做不到的事:
+**自己选了正确的工具**,而且**数字是从工具拿的,不是自己算的**。
+
+### 例 2:边界情况(测它会不会瞎编)
+
+```bash
+python main.py ask "宁波到洛杉矶一个 40 尺柜的运费是多少?"
+```
+
+预期回答会包含「没有提到」。这条在 `data/测试用例.json` 里是专门的测试用例,
+因为**「不瞎编」是 RAG 最核心的价值,必须测**。
+
+### 例 3:只用检索,看懂 RAG 原理
+
+```bash
+python main.py ask "坐标的前导零丢了会怎样" --no-llm
+```
+
+会把**实际发给大模型的完整提示词**打印出来。你会发现 RAG 说穿了就是
+「自动帮你把资料粘到问题前面」—— 不神秘。
+
+### 例 4:批量校验坐标
+
+```python
+from src import StowageCheckTool
+
+print(StowageCheckTool().run("""
+HE 010682
+f 010304
+NE 040406
+NE 040406
+"""))
+```
+
+输出会指出:`HE 010682` 的 40 尺箱写了奇数贝位、`NE 040406` 重复了。
+
+---
+
+## 🎯 项目亮点
+
+- **模块职责清晰、一一对应**:`src/` 下 7 个模块各管一件事,上面有对照表,不用在几千行代码里找「那个功能在哪」。
+- **完全手写 ReAct,没有框架黑盒**:`src/agent.py` 一共 208 行,其中真正驱动循环的不到 50 行,Thought / Action / Observation 全都打印出来给你看。
+- **知识库是真实业务规则,不是「张三李四」的假数据**:`data/配载知识库.txt` 里 21 段规则全部来自真实的配载作业经验(贝位合成规则、不同图纸排版、不同船的占位符号差异、前导零的坑……),这也是它和网上那些 RAG demo 最大的区别。
+- **两层评估,是「测试」而不是「感觉」**:工具层 15 个确定性断言 2 秒跑完,是最快的安全网;端到端 12 个用例里专门放了 2 个「不该瞎编」的边界用例。
+- **零框架依赖,`pip install` 4 个包就能跑**:可复现性最好,不会因为某个框架升级就挂掉;想换成现成的 Agent 框架,改 `src/agent.py` 一个文件就行。
+
+---
+
+## 📊 性能评估
+
+### 第一层:工具自检(离线,结果确定)
+
+```bash
+python main.py eval --offline
+```
+
+| 指标 | 结果 |
+|---|---|
+| 用例数 | 15 |
+| 通过 | 15 |
+| 通过率 | **100%** |
+
+覆盖范围:贝位合成(含非法配对报错)、大贝拆小贝、坐标拆解、舱内 / 甲板判定、
+前导零补位、六位码格式校验、40 尺箱贝位奇偶校验、重复坐标检测、知识库检索命中。
+
+> 这一层**不调用大模型**,所以结果完全确定、可重复,适合放进 CI。
+
+### 第二层:端到端评估(联网)
+
+```bash
+python main.py eval --online
+```
+
+| 指标 | 结果 |
+|---|---|
+| 用例数 | 12 |
+| 通过 | 12 |
+| 准确率 | **100%** |
+| 平均耗时 | 12.5 秒 / 题 |
+
+| 分类 | 用例数 | 准确率 |
+|---|---|---|
+| 规则问答 | 6 | 100% |
+| 会用工具 | 4 | 100% |
+| 边界情况(不该瞎编) | 2 | 100% |
+
+**真实运行片段**(`python main.py ask "..." --show-steps`):
+
+```text
+── 第 1 轮 ──
+  💭 思考:用户问贝位 09 和 11 合成的大贝号,这需要贝位计算器;层号 84 需要查配载规则。
+  🔧 行动:贝位计算器
+  👀 观察:09 + 11 → 大贝 10
+说明:40尺箱写大贝号 10;20尺箱写各自的小贝号 09 / 11
+
+── 第 2 轮 ──
+  🔧 行动:知识库检索
+  👀 观察:【资料1|相关度 0.396】
+层号决定箱子在船的什么位置:层号 02 到 08 表示箱子装在船舱内部(舱内);
+层号 82 及以上(常见 82、84、86、88)表示箱子装在甲板上(舱面)……
+
+── 第 3 轮 ──
+  ✅ 最终答案:贝位 09 和 11 合成的大贝是 10 号;层号 84 的箱子在甲板上(舱面)。
+```
+
+**边界用例的表现**(这两条最能说明 RAG 的价值):
+
+| 问题 | 回答 |
+|---|---|
+| 宁波到洛杉矶一个 40 尺柜的运费是多少? | 资料里没有提到。 |
+| 今天舟山港的风速有几级? | 资料里没有提到。 |
+
+> 普通聊天机器人会一本正经地编一个数字出来。RAG 被「只准根据资料回答」这条提示词勒住,
+> 宁可说不知道 —— 这也是抑制「模型幻觉」最有效的做法之一。
+
+### ⚠️ 两次真实的「评估驱动改进」(这段比分数本身更重要)
+
+**这两次都发生在同一天、同一个项目上。它们说明:跑评估最有价值的产出不是分数,而是「发现问题」。**
+
+#### 第一次:产品真的错了 —— 91.7% → 100%
+
+第一轮跑出来 **11/12**,挂掉的是这一题:
+
+> 读 .xls 和 .xlsx 分别要用什么 Python 库?
+
+答案在知识库里**明明有**,但 Agent 一步工具都没调(步数 = 0),直接说「资料里没有提到」。
+
+- **排查**:不是知识库缺内容,也不是检索不准 —— 是**提示词纪律不够**。
+  最初的提示词只写了「不要瞎编」,模型就理解成「不知道就可以直接说没有」,跳过了「先查再答」。
+- **改法**(`src/agent.py` 的 system prompt):
+
+```text
+3. 凡是涉及配载业务规则、图纸规则、文件读写约定这类问题,不管你自己觉不觉得知道答案,
+   都必须先调用【知识库检索】把资料拿到手,再回答。
+   没检索过就直接说"资料里没有提到",是错的。
+```
+
+- **复测**:该用例从「❌ 步数 0」变成「✅ 步数 1」,全量 **12/12**。
+
+#### 第二次:测试自己错了 —— 假失败比漏报更伤
+
+重跑一遍时又出现 **11/12**,挂掉的是:
+
+> 帮我检查一下这两个箱位对不对:HE 010682 和 f 010304
+
+但这次 Agent 的回答**完全正确**:
+
+```text
+HE 010682 不对:HE 表示 40 尺箱,应写偶数大贝号,但 01 是奇数小贝(资料1、2)。
+f 010304 可以:f 表示 20 尺重箱,应写奇数小贝号,01 符合(资料2)。
+```
+
+**问题出在断言本身**:测试用例要求回答里必须出现 `40尺`,而模型写的是 `40 尺`
+—— 数字和单位之间多了一个空格,字符串精确匹配就失败了。
+
+- **根因**:大模型写中文时经常在数字和单位间插空格(`40 尺箱`、`0.4 秒`),
+  断言死抠字面必然误报。
+- **改法**(`src/evaluate.py`):加一个 `_normalize()`,比对前把空白字符全部去掉。
+
+```python
+def _normalize(text): return re.sub(r"\s+", "", text)
+```
+
+- **复测**:**12/12**,平均耗时从 16.5 秒降到 12.5 秒。
+
+> **为什么要专门讲这一次**:端到端测试里的「假失败」(明明是好的却报错)比「漏报」更消耗信任。
+> 每误报一次,就要人工去看一遍;跑多了,大家就再也不看这份报告了。
+> **一份没人信的测试,等于没有测试。**
+
+### 已知局限
+
+诚实列一下这个项目现在做不到的事:
+
+| 局限 | 说明 |
+|---|---|
+| **知识库只有 21 段** | 内存里的 TF-IDF 检索,几万段就会变慢且不准,那时要换向量数据库(例如 Qdrant) |
+| **没有跨文档抽取** | 现在还读不了 `.xls` 配载图,只能回答「规则类」问题;接上提取脚本是下一步 |
+| **短期记忆没被测过** | 12 个端到端用例全是单轮问答,「连续对话」能力(`main.py chat`)目前缺少测试覆盖 |
+| **端到端分数会抖动** | 大模型有随机性,同一份代码跑两次可能 100% / 91.7%。**只有工具层的 15/15 是确定性的**,所以那层才适合放进 CI |
+| **关键词断言仍偏粗** | 归一化空白只能挡住「空格」这一种误报,语义等价的说法(「甲板」vs「舱面」)还得靠人工挑关键词 |
+| **TF-IDF 对近义说法吃力** | 默认的 `tfidf` 模式靠字面匹配。想让它听懂「四十英尺」↔「40尺」,要切 `--mode vector` |
+
+---
+
+## 🔮 未来计划
+
+- [ ] **接上真正的配载图** —— 现在知识库是手写的规则,下一步接到 `stowage-plan-coordinates` 的提取脚本,
+      让 Agent 能直接读 `.xls` 图纸并回答「这艘船 40 尺箱有多少个」
+- [ ] **换成向量数据库** —— 知识库到几千段以后,内存里的 TF-IDF 就不够用了,要换成 Qdrant 这类向量数据库
+- [ ] **加 MQE / HyDE** —— 两种检索增强技巧(多查询扩展、假设文档嵌入)
+- [ ] **封装成 MCP 服务器** —— 让别的 AI 工具也能调用这几个配载工具
+- [ ] **补充多轮对话测试** —— 现在的评估都是单轮问答,还没测「记忆」到底有没有起作用
+- [ ] **接入 CI** —— 每次提交自动跑第一层工具自检,红了就拦住
+
+---
+
+## 🤝 贡献指南
+
+欢迎提 Issue 和 Pull Request!
+
+- **想加自己的配载规则**:直接改 `data/配载知识库.txt`,**一个空行加一段**,不用改任何代码,跑一次 `--no-llm` 就知道有没有被检索到。
+- **想加测试用例**:改 `data/测试用例.json`,加完跑 `python main.py eval --all` 看有没有通过。
+
+---
+
+## 📄 许可证
+
+本项目采用 [MIT License](./LICENSE),可自由使用、修改、分发。
+
+> `data/配载知识库.txt` 里的配载规则来自个人作业经验总结,仅供参考学习;
+> 实际配载作业请以船公司 / 码头的正式规范为准。
+
+---
+
+## 👤 作者
+
+- GitHub: [@dandan693](https://github.com/dandan693)
+- 项目链接: [dandan693-StowageAgent](https://github.com/dandan693/hello-agents/tree/feature/stowage-agent/Co-creation-projects/dandan693-StowageAgent)
+
+---
+
+## 🙏 致谢
+
+感谢 [Datawhale](https://github.com/datawhalechina) 社区与 [Hello-Agents](https://github.com/datawhalechina/hello-agents) 项目提供的开源学习资源与共创平台。

+ 157 - 0
Co-creation-projects/dandan693-StowageAgent/data/测试用例.json

@@ -0,0 +1,157 @@
+{
+  "说明": "本文件是评估 Agent 用的测试用例。第一组测工具(离线、结果确定),第二组测端到端问答(要调大模型)。所有用例都来自 data/配载知识库.txt 里的真实配载规则。",
+  "工具自检": [
+    {
+      "案例": "成对小贝 01+03 应合成大贝 02",
+      "工具": "贝位计算器",
+      "输入": "01+03",
+      "必须包含": ["02", "大贝"]
+    },
+    {
+      "案例": "成对小贝 09+11 应合成大贝 10",
+      "工具": "贝位计算器",
+      "输入": "09,11",
+      "必须包含": ["10"]
+    },
+    {
+      "案例": "大贝 06 应能拆回 05 和 07",
+      "工具": "贝位计算器",
+      "输入": "06",
+      "必须包含": ["05", "07"]
+    },
+    {
+      "案例": "单个奇数小贝保持原样",
+      "工具": "贝位计算器",
+      "输入": "05",
+      "必须包含": ["05", "小贝"]
+    },
+    {
+      "案例": "非法配对(含偶数)应报错",
+      "工具": "贝位计算器",
+      "输入": "01+04",
+      "必须包含": ["错误"]
+    },
+    {
+      "案例": "不相邻的两个奇数不算一对",
+      "工具": "贝位计算器",
+      "输入": "01+07",
+      "必须包含": ["错误"]
+    },
+    {
+      "案例": "六位坐标拆成贝位/排位/层号",
+      "工具": "坐标解析器",
+      "输入": "010682",
+      "必须包含": ["01", "06", "82"]
+    },
+    {
+      "案例": "层号 82 应判为甲板",
+      "工具": "坐标解析器",
+      "输入": "010682",
+      "必须包含": ["甲板"]
+    },
+    {
+      "案例": "层号 04 应判为舱内",
+      "工具": "坐标解析器",
+      "输入": "020204",
+      "必须包含": ["舱内"]
+    },
+    {
+      "案例": "三个数字反向拼成六位坐标并补前导零",
+      "工具": "坐标解析器",
+      "输入": "1,6,82",
+      "必须包含": ["010682"]
+    },
+    {
+      "案例": "位数不够的坐标应报错",
+      "工具": "坐标解析器",
+      "输入": "12345",
+      "必须包含": ["错误"]
+    },
+    {
+      "案例": "合法的 40 尺/20 尺混装清单应通过校验",
+      "工具": "箱位校验器",
+      "输入": "HE 020682\nf 010304",
+      "必须包含": ["未发现"]
+    },
+    {
+      "案例": "40 尺箱写了奇数贝位应被查出来",
+      "工具": "箱位校验器",
+      "输入": "HE 010682",
+      "必须包含": ["40尺"]
+    },
+    {
+      "案例": "重复坐标应被查出来",
+      "工具": "箱位校验器",
+      "输入": "f 010304\nf 010304",
+      "必须包含": ["重复"]
+    },
+    {
+      "案例": "知识库能检索到大贝相关规则",
+      "工具": "知识库检索",
+      "输入": "40尺箱写大贝还是小贝",
+      "必须包含": ["大贝"]
+    }
+  ],
+  "端到端": [
+    {
+      "问题": "40 尺箱应该写大贝号还是小贝号?",
+      "必须包含": ["大贝"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "层号 82 的箱子是装在甲板上还是船舱里面?",
+      "必须包含": ["甲板"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "贝位 09 和贝位 11 合成的大贝是几号?",
+      "必须包含": ["10"],
+      "分类": "会用工具"
+    },
+    {
+      "问题": "把贝位 1、排位 6、层号 82 拼成六位坐标。",
+      "必须包含": ["010682"],
+      "分类": "会用工具"
+    },
+    {
+      "问题": "坐标 020486 代表什么?这个箱子在哪?",
+      "必须包含": ["甲板"],
+      "分类": "会用工具"
+    },
+    {
+      "问题": "读 .xls 和 .xlsx 分别要用什么 Python 库?",
+      "必须包含": ["xlrd", "openpyxl"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "坐标如果把前导零弄丢了,会有什么后果?",
+      "必须包含": ["前导零"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "配载图上写着 TB 的那一格,算不算箱位?",
+      "必须包含": ["锁具"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "图上出现一个 X 标记,应该怎么处理?",
+      "必须包含": ["大贝"],
+      "分类": "规则问答"
+    },
+    {
+      "问题": "帮我检查一下这两个箱位对不对:HE 010682 和 f 010304",
+      "必须包含": ["40尺"],
+      "分类": "会用工具"
+    },
+    {
+      "问题": "宁波到洛杉矶一个 40 尺柜的运费是多少?",
+      "必须包含": ["没有提到"],
+      "分类": "边界情况(不该瞎编)"
+    },
+    {
+      "问题": "今天舟山港的风速有几级?",
+      "必须包含": ["没有提到"],
+      "分类": "边界情况(不该瞎编)"
+    }
+  ]
+}

+ 41 - 0
Co-creation-projects/dandan693-StowageAgent/data/配载知识库.txt

@@ -0,0 +1,41 @@
+六位箱位坐标的编码规则:坐标 = 贝位 Bay(2 位) + 排位 Pos(2 位) + 层号 Tier(2 位),合起来是 6 位数字。例如贝位 01、排位 06、层号 82,写成 010682。三个部分都是两位,不足两位必须补前导零。
+
+40 尺箱的贝位配对规则:40 尺集装箱要占两个相邻的 20 尺小贝位,图纸上把中间那个偶数号叫"大贝"。所以 01+03 合成 02,05+07 合成 06,09+11 合成 10,13+15 合成 14,17+19 合成 18,21+23 合成 22,25+27 合成 26。规律是"大贝号 = 前一个小贝号 + 1"。
+
+40 尺箱写大贝号、20 尺箱写小贝号:40 尺箱(含标记为 + 的箱位)一律写中间那个偶数大贝号,例如 02、06、10;20 尺箱写它自己所在的具体小贝号,也就是奇数号。图上写成「(02)03」的,读作括号内 02 是大贝号、外面 03 是小贝号。
+
+层号决定箱子在船的什么位置:层号 02 到 08 表示箱子装在船舱内部(舱内);层号 82 及以上(常见 82、84、86、88)表示箱子装在甲板上(舱面)。所以坐标 010682 的箱子在甲板上,坐标 010204 的箱子在舱内。
+
+箱型标记的统一规则(最容易出错的一条):图上只有"本船箱型"保留原来的标记,其余一切非空标记都等同于旧图纸里的 X,统一改写成"+"号。"+"号代表非本航次或者占位箱位,写大贝号。
+
+船公司代码的识别方法:在配载图上全文搜索写着"代码"的单元格,读它同一列下方的值,例如 NJ,这就是本航次的码头或卸货港代码。凡是带这个代码前缀的标记都属于本船箱型,例如 NJE 就是 NJ 加上 E(空箱)。代码每个航次都会变,不能写死,必须从图纸上读出来,读不到要问配载员要。
+
+不同船舶的占位符号写法不一样:「齐合」轮用的是 X,「安洋66」轮用的是星号 *。所以程序不能只认 X 这一个符号,凡是不能识别为本船箱型的非空标记都要统一转成 "+"。
+
+配载图有三种常见排版。A 型是括号标注式,贝位写成 01 或者 「(02)03」,代表船是「齐合」轮,箱型标记是 HE(40 尺)、e(20 尺空箱)、f(20 尺重箱)。B 型是 BAY 标注式,贝位写成 BAY 11 或 B 10,代表船是「安洋66」轮,箱型标记是 F(40 尺重箱)、NE(40 尺空箱)、f(20 尺重箱)、fE(20 尺空箱)。C 型是 BAY 单格标注式,贝位写成 BAY01 或「(BAY02)」,代表船是「远翔66」轮,层号列画在图纸最左边。
+
+B 型图纸里还有一种情况:格子里是 3 到 4 位的纯数字,那表示这是一个 20 尺重箱,数字是箱重(公斤),这种情况也要当箱位提取出来。
+
+C 型图纸的箱型标记是「代码 + 重空字母」,例如 NJE。判断尺寸看后缀的大小写:全大写的是 40 尺箱,含小写字母的是 20 尺箱。
+
+哪些东西不是箱位、不要输出:写着"锁"的格子是绑扎锁位,写着"TB"的是锁具箱,空白格子和中文标点(比如句号、顿号)也都不算箱位。这些都要跳过。
+
+坐标的排序规则:先按箱型分组,A 型按 HE → e → f → + 的顺序,B 型按 F → NE → f → fE → + 的顺序,C 型按本船箱型标记 → + 的顺序;组内再按贝位、排位、层号排列。"+"号永远排在最后。
+
+排位的编号习惯:排位号从船体中线向两侧编号,奇数排位在中线右侧(右舷方向),偶数排位在中线左侧(左舷方向),00 排靠近中线。不同船公司图纸习惯略有差异,以图纸为准。图纸上排位的排列顺序常写成 06、04、02、00、01、03、05。
+
+前导零的坑(Excel 里最容易踩):坐标必须当成文本处理。如果存成数字,020682 会变成 20682、06 变成 6,六位编码直接报废。用 openpyxl 写结果表时,坐标、贝位、排位、层号这几列都要显式设成字符串类型,写完要回读一小段确认前导零还在。
+
+读取配载图用什么库:读 .xls 老格式用 xlrd,读 .xlsx 新格式用 openpyxl。写输出结果表统一用 openpyxl。
+
+坐标提取完成后的数量核对方法:必须与图纸表头给出的箱型总数交叉核对,例如表头写 40HE=200、20E=30、20F=1,B 型图纸表头写 TTL VENS=168,C 型写 40HE=250。数量对不上时,先检查有没有把"锁"和"TB"误当箱位,再检查有没有漏掉跨条的区块,最后检查贝位判定有没有把 20 尺箱写成大贝号。
+
+如果自动识别出的区块结构、贝位标注或层号列跟图纸实际画的不一致,不要硬跑自动提取,应该先停下来人工确认列区间,或者直接按图纸手工核算。宁可慢一点,也不能输出错的坐标。
+
+交付给配载员的结果表结构:一张"全部坐标"总表(序号、箱型、贝位、排位、层号、坐标、备注),外加每个箱型一张分表,最后加一张"说明"表写清楚坐标规则、贝位规则、排序规则、箱型统计、自动识别出的代码、加号的含义和核对结论。
+
+配载图里箱位是按"条带"和"区块"排布的:图纸上一个纵条代表一个贝位,横排代表层号,列代表排位。程序识别图纸结构时,要先找到层号所在的那一列,再往右逐条扫描贝位标注,才能把每个格子对应到正确的贝位。
+
+集装箱箱型的重空标记:字母 E 一般表示空箱(Empty),字母 F 表示重箱(Full)。所以 NE 是 40 尺空箱,F 是 40 尺重箱,f 是 20 尺重箱,fE 是 20 尺空箱。同一个字母大小写不同,代表的意义和尺寸都可能不一样,不能忽略大小写。
+
+本配载智能体工具的使用建议:涉及贝位合成、坐标拆分、坐标拼装、坐标清单校验这类有确定答案的计算,一律调用工具,不要靠大模型自己算,因为大模型做数字运算和格式拼装很容易出错。涉及业务规则的解释,先查知识库再回答,查不到就说没有提到。

+ 405 - 0
Co-creation-projects/dandan693-StowageAgent/main.ipynb

@@ -0,0 +1,405 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# 配载智能体 StowageAgent\n",
+    "\n",
+    "> 一个会查规则、会算贝位的集装箱船配载助手。\n",
+    "\n",
+    "这个 Notebook 是**教学版**:每一步都拆开单独演示,你按顺序跑下来,\n",
+    "就把智能体的核心模块串起来了。\n",
+    "\n",
+    "| 这一格在演示什么 | 涉及的能力 |\n",
+    "|---|---|\n",
+    "| 1. 加载配置、连上大模型 | 大模型接入 |\n",
+    "| 2. 给智能体装上\"手\"(工具) | 工具系统 |\n",
+    "| 3. 让智能体能\"翻资料\"(RAG) | 知识库检索 |\n",
+    "| 4. 让智能体\"记得住\"(记忆) | 记忆机制 |\n",
+    "| 5. 把上面几样拼成 ReAct 循环 | ReAct 循环 |\n",
+    "| 6. 给它打分(评估) | 性能评估 |\n",
+    "\n",
+    "**运行前先做一件事**:把 `.env.example` 复制成 `.env`,填上你自己的模型信息。"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 0. 准备工作\n",
+    "\n",
+    "先把项目根目录加进 Python 的搜索路径,这样 `import src` 才找得到。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import sys\n",
+    "from pathlib import Path\n",
+    "\n",
+    "# 往上找,直到找到含 src/ 的那一层,就是项目根目录\n",
+    "ROOT = Path.cwd().resolve()\n",
+    "while not (ROOT / \"src\").exists() and ROOT.parent != ROOT:\n",
+    "    ROOT = ROOT.parent\n",
+    "sys.path.insert(0, str(ROOT))\n",
+    "print(\"项目根目录:\", ROOT)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 1. 连上大模型\n",
+    "\n",
+    "大模型这一层落到代码上就一句:\n",
+    "**把 messages 发给它,拿回一段文本。**\n",
+    "\n",
+    "messages 里三种角色要分清:\n",
+    "\n",
+    "- `system` —— 定人设、定规矩(比如\"你是一名配载助理,不准瞎编\")\n",
+    "- `user` —— 用户说的话\n",
+    "- `assistant` —— 模型之前说过的话"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import LLM\n",
+    "\n",
+    "llm = LLM()\n",
+    "print(\"模型:\", llm.model)\n",
+    "\n",
+    "reply = llm.chat([\n",
+    "    {\"role\": \"system\", \"content\": \"你是一名集装箱船配载助理,回答不超过 30 字。\"},\n",
+    "    {\"role\": \"user\", \"content\": \"一句话说明 40 尺箱为什么要写大贝号。\"},\n",
+    "])\n",
+    "print(reply)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 2. 给智能体装上\"手\":工具系统\n",
+    "\n",
+    "大模型只会写字,**不会算数、不会查表**。\n",
+    "你让它是\"09+11 等于几\",它可能一本正经地答错。\n",
+    "\n",
+    "工具就是补这个短板的。一个工具需要三样东西:\n",
+    "\n",
+    "| 要素 | 作用 |\n",
+    "|---|---|\n",
+    "| `name` | 模型用它\"点名\" |\n",
+    "| `description` | **模型靠这段文字决定什么时候用它**(它看不见代码) |\n",
+    "| `run()` | 真正干活的逻辑 |\n",
+    "\n",
+    "下面 4 个工具都是纯逻辑,**不调用大模型**,所以能当单元测试来测。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import BayTool, CoordTool, StowageCheckTool\n",
+    "\n",
+    "bay = BayTool()\n",
+    "print(\"【贝位计算器】\")\n",
+    "print(bay.run(\"09+11\"))\n",
+    "print()\n",
+    "print(bay.run(\"06\"))\n",
+    "print()\n",
+    "print(bay.run(\"01+04\"))   # 故意给个错的,看它会不会报错"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "coord = CoordTool()\n",
+    "print(\"【坐标解析器】六位码 → 三要素\")\n",
+    "print(coord.run(\"010682\"))\n",
+    "print()\n",
+    "print(\"【坐标解析器】三要素 → 六位码(注意前导零)\")\n",
+    "print(coord.run(\"1,6,82\"))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "checker = StowageCheckTool()\n",
+    "print(\"【箱位校验器】故意混进几个错,看它能不能查出来\")\n",
+    "print(checker.run(\"\"\"\n",
+    "HE 010682\n",
+    "f 010304\n",
+    "NE 040406\n",
+    "NE 040406\n",
+    "\"\"\"))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 3. 让智能体能\"翻资料\":RAG\n",
+    "\n",
+    "**RAG = 开卷考试。** 不给资料,模型只能凭记忆硬答(很容易编);\n",
+    "先把资料翻出来贴到问题前面,再让它答,就靠谱多了。\n",
+    "\n",
+    "RAG 四步,`src/rag.py` 里每一步都是一个独立方法:\n",
+    "\n",
+    "1. **分块** —— 把一大篇资料切成小段(本项目:一个空行 = 一段)\n",
+    "2. **建索引** —— 给每段算一个\"指纹\"\n",
+    "3. **检索** —— 把问题也算成指纹,比一比谁最像\n",
+    "4. **拼上下文** —— 把命中的几段贴到问题前面(`src/context.py` 负责)\n",
+    "\n",
+    "后面两行都在问同一个问题,**但一次都没提到\"前导零\"这三个字**,\n",
+    "你看看它能不能靠语义找对——这就是\"第一代 vs 第二代\"。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import Retriever\n",
+    "\n",
+    "kb = Retriever(str(ROOT / \"data\" / \"配载知识库.txt\"), mode=\"tfidf\")\n",
+    "kb.index()\n",
+    "print(f\"知识库共 {len(kb.chunks)} 段资料\\n\")\n",
+    "\n",
+    "for text, score in kb.search(\"坐标写成数字会丢东西吗\", top_k=2):\n",
+    "    print(f\"[{score:.3f}] {text[:80]}…\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# 换成真·语义向量检索:更能理解\"意思\",但首次要下载约 400MB 模型\n",
+    "# 建议先把上面 tfidf 的结果记下来,再跑这个对比\n",
+    "# kb2 = Retriever(str(ROOT / \"data\" / \"配载知识库.txt\"), mode=\"vector\")\n",
+    "# kb2.index()\n",
+    "# for text, score in kb2.search(\"坐标写成数字会丢东西吗\", top_k=2):\n",
+    "#     print(f\"[{score:.3f}] {text[:80]}…\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 4. 让智能体\"记得住\":记忆\n",
+    "\n",
+    "记忆可以分好几类,落到工程上先抓住两层:\n",
+    "\n",
+    "- **短期记忆** —— 这次对话说过什么。就是 messages 列表,有长度上限,超了丢最早的\n",
+    "- **长期记忆** —— 跨对话要记住的事。写进一个文件,下次打开还在\n",
+    "\n",
+    "区别一句话:短期记忆是\"刚才聊到哪了\",长期记忆是\"这个人有什么习惯\"。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import Memory\n",
+    "\n",
+    "mem = Memory(note_path=ROOT / \"outputs\" / \"长期笔记.md\", max_messages=4)\n",
+    "for i in range(1, 7):\n",
+    "    mem.add(\"user\", f\"第{i}句话\")\n",
+    "print(\"故意说了 6 句,max_messages=4,所以只剩最近 4 句:\")\n",
+    "print(mem.recent())\n",
+    "\n",
+    "mem.add_note(\"「安洋66」轮的占位符号是星号 *,不是 X\")\n",
+    "print(\"\\n长期笔记:\", mem.recall())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 5. 全部拼起来:ReAct 循环\n",
+    "\n",
+    "**ReAct = Reason(想)+ Act(做)。**\n",
+    "\n",
+    "它和普通聊天的区别就一句话:普通聊天是\"问一句答一句\",\n",
+    "ReAct 是\"想一步、做一步、看结果、再想下一步\",能循环好几轮。\n",
+    "\n",
+    "```\n",
+    "问题 → 思考 → 行动(调工具)→ 观察(工具结果)→ 还想继续吗?\n",
+    "                              ↓ 不用了\n",
+    "                          最终答案\n",
+    "```\n",
+    "\n",
+    "下面这格会打印出它的**每一步思考**(叫 trace),你重点看两件事:\n",
+    "\n",
+    "1. 它会不会**选对工具**?(该算贝位的时候有没有去查知识库)\n",
+    "2. 它会不会**瞎编**?(资料里没有的东西,它是不是老实说\"没有提到\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import (BayTool, CoordTool, KnowledgeTool, Memory, ReActAgent,\n",
+    "                 Retriever, StowageCheckTool, ToolRegistry)\n",
+    "\n",
+    "# ① 建检索器\n",
+    "kb = Retriever(str(ROOT / \"data\" / \"配载知识库.txt\"), mode=\"tfidf\")\n",
+    "kb.index()\n",
+    "\n",
+    "# ② 注册工具\n",
+    "registry = ToolRegistry()\n",
+    "registry.register(BayTool())\n",
+    "registry.register(CoordTool())\n",
+    "registry.register(StowageCheckTool())\n",
+    "registry.register(KnowledgeTool(kb, top_k=3))\n",
+    "\n",
+    "# ③ 组装 Agent\n",
+    "agent = ReActAgent(\n",
+    "    llm=llm,\n",
+    "    registry=registry,\n",
+    "    memory=Memory(note_path=ROOT / \"outputs\" / \"长期笔记.md\"),\n",
+    "    max_steps=5,\n",
+    "    verbose=True,          # 打印每一轮的思考过程\n",
+    ")\n",
+    "\n",
+    "# ④ 问它一个\"既要用工具、又要查资料\"的复合问题\n",
+    "result = agent.run(\"贝位 09 和 11 合成的大贝是几号?层号 84 的箱子在甲板上还是船舱里?\")\n",
+    "print(\"\\n👉 最终答案:\", result[\"answer\"])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### 再看看它面对\"资料里没有的问题\"会怎么答\n",
+    "\n",
+    "这一格最能测出一个 Agent 靠不靠谱。\n",
+    "普通聊天机器人会一本正经地编一个答案,RAG 应该老实说\"没有提到\"。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "result = agent.run(\"宁波到洛杉矶一个 40 尺柜多少钱?\")\n",
+    "print(\"\\n👉 最终答案:\", result[\"answer\"])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 6. 给它打分:性能评估\n",
+    "\n",
+    "评估方法论有很多(BFCL 测工具调用、GAIA 测通用能力……)。\n",
+    "小项目抓住一个核心就够:\n",
+    "\n",
+    "> **把\"感觉它挺好\"变成\"跑 20 个用例,过了 17 个\"。**\n",
+    "\n",
+    "这里做了两层,是很典型的\"分层测试\"思路:\n",
+    "\n",
+    "| 层 | 要联网吗 | 结果确定吗 | 作用 |\n",
+    "|---|---|---|---|\n",
+    "| 工具自检 | 不要 | **确定**(算错就是算错) | 改代码后最快的安全网 |\n",
+    "| 端到端评估 | 要 | 会抖动 | 测真实的问答能力 |\n",
+    "\n",
+    "先跑免费的那层。"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from src import check_tools\n",
+    "from src.evaluate import print_tool_report\n",
+    "\n",
+    "report = check_tools(registry, ROOT / \"data\" / \"测试用例.json\")\n",
+    "print_tool_report(report)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# 端到端评估:每一题都要调一次大模型,慢而且花钱,想清楚再跑\n",
+    "# from src import evaluate_agent\n",
+    "# from src.evaluate import print_agent_report\n",
+    "# r = evaluate_agent(agent.run, ROOT / \"data\" / \"测试用例.json\")\n",
+    "# print_agent_report(r)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "---\n",
+    "## 7. 小结\n",
+    "\n",
+    "跑完这个 Notebook,你已经动手做过一遍:\n",
+    "\n",
+    "1. 怎么连大模型\n",
+    "2. 怎么把业务能力包装成工具\n",
+    "3. 怎么用 RAG 让模型\"翻资料\"\n",
+    "4. 怎么给 Agent 加记忆\n",
+    "5. 怎么把它们拼成 ReAct 循环(第 1、4 章)\n",
+    "6. 怎么评估它到底行不行\n",
+    "\n",
+    "**下一步可以自己改的地方:**\n",
+    "\n",
+    "- 往 `data/配载知识库.txt` 里加你自己的规则 —— 一个空行加一段,不用改代码\n",
+    "- 往 `data/测试用例.json` 里加测试用例 —— 加完就知道新规则有没有被\"学会\"\n",
+    "- 把 `--mode` 换成 `vector` —— 体验\"第二代检索\"强在哪\n",
+    "\n",
+    "> 📌 提醒:这是个**学习脚手架**,不是干活的生产工具。\n",
+    "> 真实场景知识库有几万段时,就该换向量数据库(Qdrant)了。"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "name": "python",
+   "version": "3.11"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}

+ 232 - 0
Co-creation-projects/dandan693-StowageAgent/main.py

@@ -0,0 +1,232 @@
+"""
+配载智能体 · 命令行入口
+
+把 src/ 里那七个模块串起来,变成一个能用的东西。
+
+常用命令::
+
+    python main.py tools                         # 看看有哪些工具
+    python main.py ask "40尺箱写哪个贝号?"        # 问一个问题
+    python main.py ask "..." --show-steps         # 顺便看它思考了几个来回
+    python main.py ask "..." --no-llm             # 只用检索,不调大模型(免费、看原理)
+    python main.py chat                           # 连续对话
+    python main.py eval --offline                 # 工具自检(免费、秒出)
+    python main.py eval --online                  # 端到端评估(要调大模型)
+    python main.py eval --all                     # 两层都跑
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+# 让 `python main.py` 在任意目录下执行都能找到 src 包
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str(ROOT))
+
+from src import (  # noqa: E402
+    BayTool,
+    CoordTool,
+    KnowledgeTool,
+    LLM,
+    LLMError,
+    Memory,
+    ReActAgent,
+    Retriever,
+    StowageCheckTool,
+    ToolRegistry,
+    check_tools,
+    evaluate_agent,
+)
+from src.evaluate import print_agent_report, print_tool_report  # noqa: E402
+
+KB_FILE = ROOT / "data" / "配载知识库.txt"
+CASE_FILE = ROOT / "data" / "测试用例.json"
+NOTE_FILE = ROOT / "outputs" / "长期笔记.md"
+
+
+# ======================================================================
+def build_registry(mode: str = "tfidf") -> ToolRegistry:
+    """装好 4 个工具(工具系统)。"""
+    print(f"⏳ 正在加载知识库并建索引(模式:{mode})…")
+    retriever = Retriever(KB_FILE, mode=mode)
+    retriever.index()
+    print(f"✅ 知识库已就绪,共 {len(retriever.chunks)} 段资料")
+
+    registry = ToolRegistry()
+    registry.register(BayTool())
+    registry.register(CoordTool())
+    registry.register(StowageCheckTool())
+    registry.register(KnowledgeTool(retriever, top_k=3))
+    return registry
+
+
+def build_agent(args) -> ReActAgent:
+    llm = LLM()
+    print(f"✅ 已接入大模型:{llm.model}")
+    registry = build_registry(args.mode)
+    # chat / eval 子命令不一定带了 --show-steps 参数,用 getattr 兜一下默认值
+    agent = ReActAgent(
+        llm=llm,
+        registry=registry,
+        memory=Memory(note_path=NOTE_FILE),
+        max_steps=getattr(args, "max_steps", 5),
+        verbose=getattr(args, "show_steps", False),
+    )
+    return agent
+
+
+# ======================================================================
+# 命令一:列出工具
+# ======================================================================
+def cmd_tools(args) -> None:
+    registry = build_registry(args.mode)
+    print("\n可用工具:\n")
+    for name in registry.names:
+        tool = registry.get(name)
+        print(f"  🔧 {name}")
+        print(f"     {tool.description}\n")
+    print("想试试?python main.py ask \"贝位 01 和 03 合成几号?\"")
+
+
+# ======================================================================
+# 命令二:问一句(--no-llm 时只做检索,看 RAG 的原理)
+# ======================================================================
+def cmd_ask(args) -> None:
+    if args.no_llm:
+        registry = build_registry(args.mode)
+        hits = registry.get("知识库检索").run(args.question)
+        print("\n" + "=" * 56)
+        print("【检索结果】(这些资料会被贴到问题前面,一起发给大模型)")
+        print("=" * 56)
+        print(hits)
+        print("\n" + "=" * 56)
+        print("【实际会发给大模型的提示词长这样】")
+        print("=" * 56)
+        print("只准根据下面资料回答,资料里没有就说「没有提到」。\n")
+        print(f"资料:\n{hits}\n")
+        print(f"问题:{args.question}")
+        return
+
+    agent = build_agent(args)
+    result = agent.run(args.question)
+
+    print("\n" + "=" * 56)
+    print("最终答案")
+    print("=" * 56)
+    print(result["answer"])
+    print("\n" + "-" * 56)
+    print(f"思考-行动轮数:{len(result['steps'])}  是否正常结束:{result['ok']}")
+
+
+# ======================================================================
+# 命令三:连续对话(演示"短期记忆")
+# ======================================================================
+def cmd_chat(args) -> None:
+    agent = build_agent(args)
+    print("\n进入连续对话模式。它能记住你前面说过的话(短期记忆)。")
+    print("输入 exit / quit 退出,输入 :clear 清空记忆。\n")
+
+    while True:
+        try:
+            question = input("你 > ").strip()
+        except (EOFError, KeyboardInterrupt):
+            print()
+            break
+        if not question:
+            continue
+        if question.lower() in {"exit", "quit", ":q"}:
+            break
+        if question == ":clear":
+            agent.memory.clear()
+            print("(已清空短期记忆)")
+            continue
+
+        result = agent.run(question)
+        print(f"\n助手 > {result['answer']}\n")
+        print("-" * 56)
+
+
+# ======================================================================
+# 命令四:评估
+# ======================================================================
+def cmd_eval(args) -> None:
+    report = {}
+
+    if args.offline or args.all:
+        registry = build_registry("tfidf")  # 离线自检固定用 tfidf,结果才稳定
+        report["工具自检"] = check_tools(registry, CASE_FILE)
+        print_tool_report(report["工具自检"])
+
+    if args.online or args.all:
+        agent = build_agent(args)
+        print("\n开始端到端评估(每一题都要调一次大模型,慢是正常的)…\n")
+        report["端到端"] = evaluate_agent(agent.run, CASE_FILE, verbose=True)
+        print_agent_report(report["端到端"])
+
+    # 报告做增量合并:只跑了一层时,不要把另一层的历史结果抹掉
+    out = ROOT / "outputs" / "评估报告.json"
+    out.parent.mkdir(parents=True, exist_ok=True)
+    merged = {}
+    if out.exists():
+        try:
+            merged = json.loads(out.read_text(encoding="utf-8"))
+        except json.JSONDecodeError:
+            merged = {}
+    merged.update(report)
+    out.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"\n📄 报告已保存:{out}(本次覆盖的层:{'、'.join(report)})")
+
+
+# ======================================================================
+def main() -> int:
+    parser = argparse.ArgumentParser(
+        description="配载智能体 —— 集装箱船配载规则问答与坐标计算",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=__doc__,
+    )
+    parser.add_argument("--mode", default="tfidf", choices=["tfidf", "vector"],
+                        help="检索模式:tfidf(默认,秒开)或 vector(语义向量,首次要下模型)")
+    parser.add_argument("--max-steps", type=int, default=5, help="ReAct 最多循环几轮")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    sub.add_parser("tools", help="列出所有工具")
+
+    p_ask = sub.add_parser("ask", help="问一个问题")
+    p_ask.add_argument("question", help="你要问的话")
+    p_ask.add_argument("--show-steps", action="store_true", help="打印每一轮的思考过程")
+    p_ask.add_argument("--no-llm", action="store_true", help="只用检索,不调用大模型")
+
+    sub.add_parser("chat", help="连续对话")
+
+    p_eval = sub.add_parser("eval", help="跑评估")
+    p_eval.add_argument("--offline", action="store_true", help="只跑工具自检(不花钱)")
+    p_eval.add_argument("--online", action="store_true", help="只跑端到端评估")
+    p_eval.add_argument("--all", action="store_true", help="两层都跑")
+    p_eval.add_argument("--show-steps", action="store_true", help="端到端评估时打印思考过程")
+
+    args = parser.parse_args()
+    if args.command == "eval" and not (args.offline or args.online or args.all):
+        args.offline = True  # 默认跑最便宜的那层
+
+    try:
+        {
+            "tools": cmd_tools,
+            "ask": cmd_ask,
+            "chat": cmd_chat,
+            "eval": cmd_eval,
+        }[args.command](args)
+    except LLMError as exc:
+        print(f"\n❌ 大模型调用出问题:{exc}")
+        print("   检查一下 .env 里的 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL_ID。")
+        return 1
+    except FileNotFoundError as exc:
+        print(f"\n❌ 找不到文件:{exc}")
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 272 - 0
Co-creation-projects/dandan693-StowageAgent/outputs/评估报告.json

@@ -0,0 +1,272 @@
+{
+  "工具自检": {
+    "总数": 15,
+    "通过": 15,
+    "明细": [
+      {
+        "案例": "成对小贝 01+03 应合成大贝 02",
+        "工具": "贝位计算器",
+        "输入": "01+03",
+        "必须包含": [
+          "02",
+          "大贝"
+        ],
+        "通过": true,
+        "实际输出": "01 + 03 → 大贝 02\n说明:40尺箱写大贝号 02;20尺箱写各自的小贝号 01 / 03"
+      },
+      {
+        "案例": "成对小贝 09+11 应合成大贝 10",
+        "工具": "贝位计算器",
+        "输入": "09,11",
+        "必须包含": [
+          "10"
+        ],
+        "通过": true,
+        "实际输出": "09 + 11 → 大贝 10\n说明:40尺箱写大贝号 10;20尺箱写各自的小贝号 09 / 11"
+      },
+      {
+        "案例": "大贝 06 应能拆回 05 和 07",
+        "工具": "贝位计算器",
+        "输入": "06",
+        "必须包含": [
+          "05",
+          "07"
+        ],
+        "通过": true,
+        "实际输出": "大贝 06 → 由小贝 05 和 07 合成\n说明:这个贝位上的 40 尺箱写 06,20 尺箱分别写 05 / 07"
+      },
+      {
+        "案例": "单个奇数小贝保持原样",
+        "工具": "贝位计算器",
+        "输入": "05",
+        "必须包含": [
+          "05",
+          "小贝"
+        ],
+        "通过": true,
+        "实际输出": "05 是小贝号(奇数),保持原样\n说明:20 尺箱写小贝号 05;如果这里装 40 尺箱,应写成大贝号 06"
+      },
+      {
+        "案例": "非法配对(含偶数)应报错",
+        "工具": "贝位计算器",
+        "输入": "01+04",
+        "必须包含": [
+          "错误"
+        ],
+        "通过": true,
+        "实际输出": "错误:01 和 04 里有偶数。成对的小贝必须是两个奇数(如 01+03)"
+      },
+      {
+        "案例": "不相邻的两个奇数不算一对",
+        "工具": "贝位计算器",
+        "输入": "01+07",
+        "必须包含": [
+          "错误"
+        ],
+        "通过": true,
+        "实际输出": "错误:01 和 07 不相邻,不是一对小贝(相邻的两个奇数才成对)"
+      },
+      {
+        "案例": "六位坐标拆成贝位/排位/层号",
+        "工具": "坐标解析器",
+        "输入": "010682",
+        "必须包含": [
+          "01",
+          "06",
+          "82"
+        ],
+        "通过": true,
+        "实际输出": "坐标 010682 拆解:\n  贝位 Bay = 01\n  排位 Pos = 06(偶数排位,通常在中线左侧 / 左舷方向)\n  层号 Tier = 82 → 舱面(甲板)"
+      },
+      {
+        "案例": "层号 82 应判为甲板",
+        "工具": "坐标解析器",
+        "输入": "010682",
+        "必须包含": [
+          "甲板"
+        ],
+        "通过": true,
+        "实际输出": "坐标 010682 拆解:\n  贝位 Bay = 01\n  排位 Pos = 06(偶数排位,通常在中线左侧 / 左舷方向)\n  层号 Tier = 82 → 舱面(甲板)"
+      },
+      {
+        "案例": "层号 04 应判为舱内",
+        "工具": "坐标解析器",
+        "输入": "020204",
+        "必须包含": [
+          "舱内"
+        ],
+        "通过": true,
+        "实际输出": "坐标 020204 拆解:\n  贝位 Bay = 02\n  排位 Pos = 02(偶数排位,通常在中线左侧 / 左舷方向)\n  层号 Tier = 04 → 舱内"
+      },
+      {
+        "案例": "三个数字反向拼成六位坐标并补前导零",
+        "工具": "坐标解析器",
+        "输入": "1,6,82",
+        "必须包含": [
+          "010682"
+        ],
+        "通过": true,
+        "实际输出": "贝位01 + 排位06 + 层号82 → 坐标 010682\n位置:舱面(甲板)"
+      },
+      {
+        "案例": "位数不够的坐标应报错",
+        "工具": "坐标解析器",
+        "输入": "12345",
+        "必须包含": [
+          "错误"
+        ],
+        "通过": true,
+        "实际输出": "错误:'12345' 不是合法的六位坐标(应为 6 位数字,如 010682)"
+      },
+      {
+        "案例": "合法的 40 尺/20 尺混装清单应通过校验",
+        "工具": "箱位校验器",
+        "输入": "HE 020682\nf 010304",
+        "必须包含": [
+          "未发现"
+        ],
+        "通过": true,
+        "实际输出": "共收到 2 个箱位,去重后 2 个\n箱型统计:HE=1  f=1\n✅ 未发现格式或贝位问题"
+      },
+      {
+        "案例": "40 尺箱写了奇数贝位应被查出来",
+        "工具": "箱位校验器",
+        "输入": "HE 010682",
+        "必须包含": [
+          "40尺"
+        ],
+        "通过": true,
+        "实际输出": "共收到 1 个箱位,去重后 1 个\n箱型统计:HE=1\n⚠️ 发现 1 处问题:\n  - 'HE 010682':40尺箱应写偶数大贝号,但贝位是 01"
+      },
+      {
+        "案例": "重复坐标应被查出来",
+        "工具": "箱位校验器",
+        "输入": "f 010304\nf 010304",
+        "必须包含": [
+          "重复"
+        ],
+        "通过": true,
+        "实际输出": "共收到 2 个箱位,去重后 1 个\n箱型统计:f=2\n⚠️ 重复坐标:010304\n✅ 未发现格式或贝位问题"
+      },
+      {
+        "案例": "知识库能检索到大贝相关规则",
+        "工具": "知识库检索",
+        "输入": "40尺箱写大贝还是小贝",
+        "必须包含": [
+          "大贝"
+        ],
+        "通过": true,
+        "实际输出": "【资料1|相关度 0.473】\n40 尺箱写大贝号、20 尺箱写小贝号:40 尺箱(含标记为 + 的箱位)一律写中间那个偶数大贝号,例如 02、06、10;20 尺箱写它自己所在的具体小贝号,也就是奇数号。图上写成「(02)03」的,读作括号内 02 是大贝号、外面 03 是小贝号。\n\n【资料2|相关度 0.223】\nC 型图纸的箱型标记是「代码 + 重空字母」,例如 NJE。判断尺寸看后缀的大小写:全大写的是 40 尺箱,含小写字母的是 20 尺箱。\n\n【资料3|相关度 0.179】\n40 尺箱的贝位配对规则:40 尺集装箱要占两个相邻的 20 尺小贝位,图纸上把中间那个偶数号叫\"大贝\"。所以 01+03 合成 02,05+07 合成 06,09+11 合成 10,13+15 合成 14,17+19 合成 18,21+23 合成 22,25+27 合成 26。规律是\"大贝号 = 前一个小贝号 + 1\"。"
+      }
+    ]
+  },
+  "端到端": {
+    "总数": 12,
+    "通过": 12,
+    "准确率": 100.0,
+    "平均耗时": 12.49,
+    "分类准确率": {
+      "规则问答": 100.0,
+      "会用工具": 100.0,
+      "边界情况(不该瞎编)": 100.0
+    },
+    "明细": [
+      {
+        "问题": "40 尺箱应该写大贝号还是小贝号?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "40 尺箱应写大贝号,即中间那个偶数号,例如 02、06、10(资料1)。",
+        "步数": 1,
+        "耗时": 6.93
+      },
+      {
+        "问题": "层号 82 的箱子是装在甲板上还是船舱里面?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "层号 82 的箱子装在甲板上(舱面)。依据:资料1。",
+        "步数": 1,
+        "耗时": 8.99
+      },
+      {
+        "问题": "贝位 09 和贝位 11 合成的大贝是几号?",
+        "分类": "会用工具",
+        "通过": true,
+        "回答": "贝位 09 和贝位 11 合成的大贝是 10 号。",
+        "步数": 2,
+        "耗时": 19.51
+      },
+      {
+        "问题": "把贝位 1、排位 6、层号 82 拼成六位坐标。",
+        "分类": "会用工具",
+        "通过": true,
+        "回答": "010682",
+        "步数": 1,
+        "耗时": 7.95
+      },
+      {
+        "问题": "坐标 020486 代表什么?这个箱子在哪?",
+        "分类": "会用工具",
+        "通过": true,
+        "回答": "坐标 020486 表示:贝位 02、排位 04、层号 86。根据资料1,层号 86 属于舱面(甲板);根据资料2,排位 04 为偶数,通常在中线左侧/左舷方向。因此这个箱子在甲板左舷。",
+        "步数": 2,
+        "耗时": 29.39
+      },
+      {
+        "问题": "读 .xls 和 .xlsx 分别要用什么 Python 库?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "读 .xls 老格式用 xlrd,读 .xlsx 新格式用 openpyxl;写输出结果表统一用 openpyxl(资料1)。",
+        "步数": 1,
+        "耗时": 8.59
+      },
+      {
+        "问题": "坐标如果把前导零弄丢了,会有什么后果?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "坐标前导零丢失会导致六位箱位坐标直接报废(资料1)。例如 020682 会变成 20682、06 变成 6,系统无法再按“贝位2位+排位2位+层号2位”正确解析,贝位、排位、层号会发生错位或无法识别(资料1、资料2)。",
+        "步数": 1,
+        "耗时": 9.41
+      },
+      {
+        "问题": "配载图上写着 TB 的那一格,算不算箱位?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "不算箱位。“TB”是锁具箱,按资料1规定应跳过,不作为箱位输出。",
+        "步数": 1,
+        "耗时": 7.15
+      },
+      {
+        "问题": "图上出现一个 X 标记,应该怎么处理?",
+        "分类": "规则问答",
+        "通过": true,
+        "回答": "图上出现 X 标记时,应把它视为非本船箱型/占位标记,统一改写成“+”号;“+”代表非本航次或者占位箱位,写大贝号。不能只认 X 这一个符号,其他不能识别为本船箱型的非空标记也都要统一转成“+”。(资料2、资料3)",
+        "步数": 1,
+        "耗时": 9.56
+      },
+      {
+        "问题": "帮我检查一下这两个箱位对不对:HE 010682 和 f 010304",
+        "分类": "会用工具",
+        "通过": true,
+        "回答": "这两个箱位中,“HE 010682”不对:HE 是40尺箱,应写偶数大贝号,但坐标贝位是01。“f 010304”校验未发现问题。",
+        "步数": 2,
+        "耗时": 30.84
+      },
+      {
+        "问题": "宁波到洛杉矶一个 40 尺柜的运费是多少?",
+        "分类": "边界情况(不该瞎编)",
+        "通过": true,
+        "回答": "资料里没有提到。",
+        "步数": 0,
+        "耗时": 6.03
+      },
+      {
+        "问题": "今天舟山港的风速有几级?",
+        "分类": "边界情况(不该瞎编)",
+        "通过": true,
+        "回答": "资料里没有提到。",
+        "步数": 0,
+        "耗时": 5.53
+      }
+    ]
+  }
+}

+ 10 - 0
Co-creation-projects/dandan693-StowageAgent/requirements.txt

@@ -0,0 +1,10 @@
+# ===== 核心依赖(装这几个就能跑)=====
+openai>=1.30.0
+python-dotenv>=1.0.0
+scikit-learn>=1.3.0
+numpy>=1.24.0
+
+# ===== 可选:语义向量检索 =====
+# 只有用 `--mode vector` 时才需要。它会额外下载约 400MB 的中文向量模型,
+# 第一次跑建议先用默认的 tfidf 模式,跑通流程再说。
+# sentence-transformers>=3.0.0

+ 42 - 0
Co-creation-projects/dandan693-StowageAgent/src/__init__.py

@@ -0,0 +1,42 @@
+"""配载智能体的核心模块。
+
+各模块职责:
+
+    llm.py       大语言模型基础(怎么跟模型说话)
+    tools.py     工具系统(给智能体装上"手")
+    memory.py    记忆系统(短期对话 + 长期笔记)
+    rag.py       检索增强(让模型能"翻资料")
+    context.py   上下文工程(拼提示词、裁长度)
+    agent.py     ReAct 循环(想一步、做一步)
+    evaluate.py  性能评估(给 Agent 打分)
+"""
+
+from .agent import ReActAgent
+from .context import ContextBuilder
+from .evaluate import check_tools, evaluate_agent
+from .llm import LLM, LLMError
+from .memory import Memory
+from .rag import Retriever
+from .tools import (
+    BayTool,
+    CoordTool,
+    KnowledgeTool,
+    StowageCheckTool,
+    ToolRegistry,
+)
+
+__all__ = [
+    "LLM",
+    "LLMError",
+    "ReActAgent",
+    "ContextBuilder",
+    "Memory",
+    "Retriever",
+    "ToolRegistry",
+    "BayTool",
+    "CoordTool",
+    "StowageCheckTool",
+    "KnowledgeTool",
+    "check_tools",
+    "evaluate_agent",
+]

+ 208 - 0
Co-creation-projects/dandan693-StowageAgent/src/agent.py

@@ -0,0 +1,208 @@
+"""
+ReAct 循环(Reason 想 + Act 做)。
+
+ReAct = Reason(想)+ Act(做)。
+它跟普通聊天的区别只有一句话:**普通聊天是"问一句答一句",
+ReAct 是"想一步、做一步、看结果、再想下一步",能循环好几轮。**
+
+这个循环的流程图如下,本文件就是它的代码版:
+
+    ┌──────────────────────────────────────┐
+    │  问题                                 │
+    │    ↓                                 │
+    │  思考(Thought)  ← 模型自己想        │
+    │    ↓                                 │
+    │  行动(Action)   ← 模型点名要哪个工具 │
+    │    ↓                                 │
+    │  观察(Observation)← 我们执行工具    │←─┐
+    │    ↓                                 │  │ 没做完
+    │  做完了吗? ──没完───────────────────┼──┘
+    │    │完                                │
+    │    ↓                                 │
+    │  最终答案(Final Answer)             │
+    └──────────────────────────────────────┘
+
+⚠️ 这里故意**没用任何框架**,全是手写的。因为 ReAct 的骨架就这么点东西,
+   看懂这几十行,一个智能体内部在干什么就清楚了。
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any, Dict, List, Tuple
+
+from .context import ContextBuilder
+from .llm import LLM
+from .memory import Memory
+from .tools import ToolRegistry
+
+# ----------------------------------------------------------------------
+# 系统提示词 —— 这封信决定了 Agent 的性格和纪律
+# ----------------------------------------------------------------------
+REACT_SYSTEM_PROMPT = """你是一名集装箱船配载助理,服务于码头配载员。
+
+你的能力边界:
+- 你可以调用下面列出的工具来获取准确结果。
+- 你不知道的规则,要先用工具查,**绝对不要凭印象编造**。
+- 如果工具查不到、资料里也没有,就直接回答"资料里没有提到",不要猜。
+
+可用工具:
+{tools}
+
+请严格按下面两种格式之一回答,不要加任何多余的话:
+
+格式 A(还需要用工具时):
+思考:<你为什么需要这个工具>
+行动:<工具名,必须是上面列出的名字之一>
+行动输入:<给这个工具的输入,纯文本一行>
+
+格式 B(已经能回答了):
+思考:<你的推理>
+最终答案:<给用户的回答,中文,简洁,需要时引用资料编号>
+
+铁律:
+1. 一次只调用一个工具。
+2. "行动:"后面只能写工具名本身,不能带括号、不能带别的话。
+3. **凡是涉及配载业务规则、图纸规则、文件读写约定这类问题,不管你自己觉不觉得知道答案,
+   都必须先调用【知识库检索】把资料拿到手,再回答。**
+   没检索过就直接说"资料里没有提到",是错的。
+4. 只在"已经检索过、工具也返回了结果"之后,才可以直接给最终答案。
+   这时候还反复调用工具就是浪费。
+5. 涉及具体数字(贝位、层号、坐标)时,必须以工具返回的结果为准,不要自己心算。
+"""
+
+
+class ReActAgent:
+    """最小可用的 ReAct 智能体。"""
+
+    def __init__(
+        self,
+        llm: LLM,
+        registry: ToolRegistry,
+        memory: Memory | None = None,
+        system_prompt: str | None = None,
+        max_steps: int = 5,
+        verbose: bool = True,
+    ) -> None:
+        self.llm = llm
+        self.registry = registry
+        self.memory = memory or Memory()
+        self.max_steps = max_steps
+        self.verbose = verbose
+
+        self.system_prompt = (system_prompt or REACT_SYSTEM_PROMPT).format(
+            tools=registry.describe()
+        )
+        self.context = ContextBuilder(self.system_prompt)
+
+    # ------------------------------------------------------------------
+    def run(self, question: str) -> Dict[str, Any]:
+        """跑完一轮完整的思考-行动循环。
+
+        返回 {'answer': 最终答案, 'steps': [(思考, 行动, 观察), …], 'ok': 是否正常结束}
+        """
+        steps: List[Tuple[str, str, str]] = []
+        self.memory.add("user", question)
+
+        for i in range(1, self.max_steps + 1):
+            if self.verbose:
+                print(f"\n── 第 {i} 轮 ──")
+
+            reply = self._think(question, steps)
+            thought, action, action_input, final = self._parse(reply)
+
+            # 情况一:模型觉得可以收工了
+            if final:
+                if self.verbose:
+                    print(f"  💭 思考:{thought}")
+                    print(f"  ✅ 最终答案:{final}")
+                self.memory.add("assistant", final)
+                return {"answer": final, "steps": steps, "ok": True}
+
+            # 情况二:模型要调工具
+            if action:
+                if self.verbose:
+                    print(f"  💭 思考:{thought}")
+                    print(f"  🔧 行动:{action}")
+                    print(f"  📥 输入:{action_input}")
+                observation = self._use_tool(action, action_input)
+                if self.verbose:
+                    preview = observation if len(observation) < 300 else observation[:300] + " …"
+                    print(f"  👀 观察:{preview}")
+                steps.append((thought, action, observation))
+                continue
+
+            # 情况三:格式没按规矩来,提醒它一次
+            if self.verbose:
+                print(f"  ⚠️ 格式不对,提醒模型重来。原始回复:{reply[:120]}")
+            steps.append((thought, "", "(你的上一条回复不符合格式,请严格按 思考/行动/行动输入,或 思考/最终答案 来写)"))
+
+        # 转 max_steps 轮还没收工,兜底
+        fallback = "抱歉,我试了几种办法都没能得到确切答案。建议补充一下具体船名或图纸信息。"
+        self.memory.add("assistant", fallback)
+        return {"answer": fallback, "steps": steps, "ok": False}
+
+    # ------------------------------------------------------------------
+    def _think(self, question: str, steps: List[Tuple[str, str, str]]) -> str:
+        """把当前状态拼成一封信,让模型回一句。"""
+        scratchpad = ContextBuilder.build_agent_scratchpad(steps)
+        user_block = f"问题:{question}"
+        if scratchpad:
+            user_block += f"\n\n你已经走过的步骤:\n{scratchpad}\n\n请继续。"
+
+        messages = [
+            {"role": "system", "content": self.system_prompt},
+            {"role": "user", "content": user_block},
+        ]
+        return self.llm.chat(messages, temperature=0.0)
+
+    # ------------------------------------------------------------------
+    @staticmethod
+    def _parse(reply: str) -> Tuple[str, str, str, str]:
+        """从模型的一坨文字里,抠出"思考 / 行动 / 行动输入 / 最终答案"。
+
+        解析这件事看起来很土,但它是 Agent 最脆弱的一环——
+        **模型只要少写一个冒号,整个循环就会卡住**。所以这里写得尽量宽松。
+        """
+        text = (reply or "").replace("\r\n", "\n").strip()
+
+        def grab(label: str, stop: List[str]) -> str:
+            """从 `label:` 后面一直取到下一个标签之前。"""
+            pattern = rf"{label}\s*[::]\s*(.*?)(?=\n\s*(?:{'|'.join(stop)})\s*[::]|$)"
+            match = re.search(pattern, text, re.S)
+            return match.group(1).strip() if match else ""
+
+        labels = ["思考", "行动", "行动输入", "最终答案", "观察"]
+        thought = grab("思考", labels)
+        final = grab("最终答案", labels)
+
+        if final:
+            return thought, "", "", final
+
+        action = grab("行动", labels)
+        action_input = grab("行动输入", labels)
+
+        # 有些模型会把工具名和输入写在一行,例如 "行动:贝位计算器 01+03"
+        # 这种情况下面会找不到"行动输入",就把工具名后面剩下的字当输入
+        if action and not action_input:
+            parts = action.split(maxsplit=1)
+            if len(parts) == 2:
+                action, action_input = parts[0], parts[1]
+
+        # 去掉模型爱加的各种装饰
+        action = re.sub(r"[`*\"'()()]", "", action).strip()
+        action_input = re.sub(r"^[`*\"']|[`*\"']$", "", action_input).strip()
+        return thought, action, action_input, ""
+
+    # ------------------------------------------------------------------
+    def _use_tool(self, name: str, query: str) -> str:
+        """执行工具,并把任何异常变成一句"人话"回喂给模型。"""
+        tool = self.registry.get(name)
+        if tool is None:
+            return (
+                f"没有叫 '{name}' 的工具。可用工具只有:{', '.join(self.registry.names)}"
+            )
+        try:
+            return tool.run(query)
+        except Exception as exc:  # noqa: BLE001 - 工具报错也要让循环继续
+            return f"工具 '{name}' 执行出错:{exc}"

+ 91 - 0
Co-creation-projects/dandan693-StowageAgent/src/context.py

@@ -0,0 +1,91 @@
+"""
+上下文工程 → 往模型的脑子里"塞什么、塞多少、怎么摆"。
+
+有句话很关键:**模型的智商是固定的,你能改变的只有上下文。**
+同一个模型,喂给它的提示词不一样,表现能差出十万八千里。
+
+这个文件只干一件事:把散落各处的东西拼成一封给模型的信:
+
+    系统提示词(你是谁、有什么工具、守什么规矩)
+      + 长期笔记(跨对话该记住的事)
+      + 检索到的资料(RAG 查回来的规则原文)
+      + 最近几轮对话(短期记忆)
+      + 这次的问题
+    = 最终发给模型的 messages
+
+另外顺手做了"裁长度":模型能读的字数是有限的(叫"上下文窗口"),
+塞太多了会被截断或者直接报错,所以超预算时先把老对话丢掉。
+"""
+
+from __future__ import annotations
+
+from typing import Dict, List, Tuple
+
+#: 给"历史对话"留的字数预算。超了就丢最早的。
+DEFAULT_HISTORY_BUDGET = 2000
+
+
+class ContextBuilder:
+    def __init__(
+        self,
+        system_prompt: str = "",
+        history_budget: int = DEFAULT_HISTORY_BUDGET,
+    ) -> None:
+        self.system_prompt = system_prompt
+        self.history_budget = history_budget
+
+    # ------------------------------------------------------------------
+    @staticmethod
+    def trim_history(
+        history: List[Dict[str, str]],
+        budget: int = DEFAULT_HISTORY_BUDGET,
+    ) -> List[Dict[str, str]]:
+        """从最新的一轮往前数,攒够 budget 个字就停。"""
+        kept: List[Dict[str, str]] = []
+        used = 0
+        for msg in reversed(history):
+            size = len(msg.get("content", ""))
+            if used + size > budget and kept:
+                break
+            kept.append(msg)
+            used += size
+        return list(reversed(kept))
+
+    # ------------------------------------------------------------------
+    def build_api_messages(
+        self,
+        question: str,
+        history: List[Dict[str, str]] | None = None,
+        extra_system: str = "",
+    ) -> List[Dict[str, str]]:
+        """拼出一封"普通问答"的信(不涉及工具调用)。
+
+        给 --no-llm 之外的纯问答场景用,比如让模型直接读资料回答。
+        """
+        system = self.system_prompt + ("\n\n" + extra_system if extra_system else "")
+        messages: List[Dict[str, str]] = [{"role": "system", "content": system}]
+        messages.extend(self.trim_history(history or [], self.history_budget))
+        messages.append({"role": "user", "content": question})
+        return messages
+
+    # ------------------------------------------------------------------
+    @staticmethod
+    def build_agent_scratchpad(
+        steps: List[Tuple[str, str, str]],
+    ) -> str:
+        """把已经走过的步骤拼成一段"草稿纸",回喂给模型。
+
+        steps 里每一项是 (思考, 行动, 观察结果)。
+        有了这段草稿纸,模型才知道自己刚才干过什么、别重复做。
+        """
+        if not steps:
+            return ""
+        lines: List[str] = []
+        for thought, action, observation in steps:
+            if thought:
+                lines.append(f"思考:{thought}")
+            if action:
+                lines.append(f"行动:{action}")
+            if observation:
+                lines.append(f"观察:{observation}")
+        return "\n".join(lines)

+ 165 - 0
Co-creation-projects/dandan693-StowageAgent/src/evaluate.py

@@ -0,0 +1,165 @@
+"""
+性能评估 → 给自己的 Agent 打分。
+
+评估方法论有很多(BFCL 测工具调用、GAIA 测通用能力……)。
+对小项目来说,抓住一个核心就够:
+
+    **把"感觉它挺好"变成"跑 20 个用例,过了 17 个"。**
+
+本文件做了两层评估,是很典型的"分层测试"思路:
+
+  第一层:工具自检(离线、免费、秒出结果)
+    不调用大模型,直接测工具函数的输入输出。
+    判定标准是**确定的**——算错了就是算错了,没有"大概对"。
+    → 代码逻辑一旦改动,先跑这层,它是最快的安全网。
+
+  第二层:端到端评估(需要联网调大模型)
+    真的把问题丢给 Agent,看它的回答有没有答到点子上。
+    因为大模型的回答每次可能不一样,所以判定不能要求"一字不差",
+    而是"必须包含这几个关键词"。
+    → 这层的分数天然会抖动,跑一次 88%、再跑一次 94% 都正常。
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import time
+from pathlib import Path
+from typing import Any, Callable, Dict, List
+
+
+def _normalize(text: str) -> str:
+    """把空白字符全去掉,再比对关键词。
+
+    为什么需要这一步:大模型写中文时常常在数字和单位之间插空格,
+    "40尺箱" 会被写成 "40 尺箱","0.4 秒" 会被写成 "0.4秒"。
+    如果断言死抠字面,就会出现**回答其实完全正确、却被判成失败**的假失败。
+
+    这是个真实的教训:端到端评估里的"假失败"比"漏报"更消耗信任,
+    每次误报都要人工去看一遍,跑多了就没人信这套测试了。
+    """
+    return re.sub(r"\s+", "", text)
+
+
+def _hit(answer: str, keywords: List[str], exclude: List[str] | None = None) -> bool:
+    """回答里是否包含全部关键词(忽略空格差异),且不含排除词。"""
+    body = _normalize(answer)
+    if not all(_normalize(kw) in body for kw in keywords):
+        return False
+    return not any(_normalize(kw) in body for kw in (exclude or []))
+
+
+# ======================================================================
+# 第一层:工具自检(不花钱、可重复、结果确定)
+# ======================================================================
+def check_tools(registry, case_file: str | Path) -> Dict[str, Any]:
+    cases = json.loads(Path(case_file).read_text(encoding="utf-8"))["工具自检"]
+
+    results: List[Dict[str, Any]] = []
+    for case in cases:
+        tool = registry.get(case["工具"])
+        if tool is None:
+            results.append({**case, "通过": False, "实际输出": f"找不到工具 {case['工具']}"})
+            continue
+        try:
+            output = tool.run(case["输入"])
+        except Exception as exc:  # noqa: BLE001
+            output = f"抛异常:{exc}"
+
+        ok = _hit(output, case["必须包含"], case.get("不能包含"))
+        results.append({**case, "通过": ok, "实际输出": output})
+
+    passed = sum(1 for r in results if r["通过"])
+    return {"总数": len(results), "通过": passed, "明细": results}
+
+
+# ======================================================================
+# 第二层:端到端评估(要调大模型)
+# ======================================================================
+def evaluate_agent(
+    ask: Callable[[str], Dict[str, Any]],
+    case_file: str | Path,
+    verbose: bool = True,
+) -> Dict[str, Any]:
+    """`ask` 是一个"问一句、返回结果字典"的函数(通常是 agent.run)。"""
+    cases = json.loads(Path(case_file).read_text(encoding="utf-8"))["端到端"]
+
+    results: List[Dict[str, Any]] = []
+    for i, case in enumerate(cases, 1):
+        started = time.time()
+        try:
+            out = ask(case["问题"])
+            answer = out.get("answer", "")
+            n_steps = len(out.get("steps", []))
+        except Exception as exc:  # noqa: BLE001
+            answer, n_steps = f"跑挂了:{exc}", 0
+        elapsed = time.time() - started
+
+        ok = _hit(answer, case["必须包含"])
+        results.append(
+            {
+                "问题": case["问题"],
+                "分类": case.get("分类", "未分类"),
+                "通过": ok,
+                "回答": answer,
+                "步数": n_steps,
+                "耗时": round(elapsed, 2),
+            }
+        )
+
+        if verbose:
+            print(f"  [{i}/{len(cases)}] {'✅' if ok else '❌'} {case['问题'][:36]}…")
+
+    passed = sum(1 for r in results if r["通过"])
+    total_time = sum(r["耗时"] for r in results)
+
+    # 按分类汇总,找出哪一类最弱 —— 这才是评估真正的用处
+    by_category: Dict[str, List[bool]] = {}
+    for r in results:
+        by_category.setdefault(r["分类"], []).append(r["通过"])
+
+    return {
+        "总数": len(results),
+        "通过": passed,
+        "准确率": round(passed / len(results) * 100, 1) if results else 0.0,
+        "平均耗时": round(total_time / len(results), 2) if results else 0.0,
+        "分类准确率": {
+            k: round(sum(v) / len(v) * 100, 1) for k, v in by_category.items()
+        },
+        "明细": results,
+    }
+
+
+# ======================================================================
+# 打印报告
+# ======================================================================
+def print_tool_report(report: Dict[str, Any]) -> None:
+    print("\n" + "=" * 56)
+    print("第一层:工具自检(不调用大模型)")
+    print("=" * 56)
+    for r in report["明细"]:
+        print(f"  {'✅' if r['通过'] else '❌'} {r['案例']}")
+        print(f"      输入:{r['输入']}")
+        if not r["通过"]:
+            print(f"      期望包含:{r['必须包含']}")
+            print(f"      实际输出:{r['实际输出'].replace(chr(10), ' | ')[:160]}")
+    print(f"\n  结果:{report['通过']} / {report['总数']} 通过")
+
+
+def print_agent_report(report: Dict[str, Any]) -> None:
+    print("\n" + "=" * 56)
+    print("第二层:端到端评估(调用了大模型)")
+    print("=" * 56)
+    for r in report["明细"]:
+        flag = "✅" if r["通过"] else "❌"
+        print(f"\n{flag} [{r['分类']}] {r['问题']}")
+        print(f"   回答:{r['回答'].replace(chr(10), ' ')[:200]}")
+        print(f"   用了 {r['步数']} 步,耗时 {r['耗时']} 秒")
+
+    print("\n" + "-" * 56)
+    print(f"  总准确率:{report['准确率']}%  ({report['通过']}/{report['总数']})")
+    print(f"  平均耗时:{report['平均耗时']} 秒/题")
+    print("  分类准确率:")
+    for name, acc in report["分类准确率"].items():
+        print(f"    {name:<12} {acc}%")

+ 150 - 0
Co-creation-projects/dandan693-StowageAgent/src/llm.py

@@ -0,0 +1,150 @@
+"""
+把"跟大模型说话"这件事封装好。
+
+三个要点:
+
+  提示工程  →  messages 里 system(定人设)/ user(用户说)/ assistant(模型说)
+                     三种角色。角色不同,模型的态度就不同。
+  单次请求  → 一次请求返回一段文本。本文件默认用"非流式"(好解析),
+                        另留了 stream() 做打字机效果。
+  抑制幻觉  →  temperature 默认 0。温度越低,回答越稳定、越可复现,
+                     这样评估脚本跑出来的分数才有意义。
+
+只要你的模型服务兼容 OpenAI 接口(DeepSeek / 通义 / 智谱 / 本地 vLLM 都兼容),
+这个类就能直接用。
+"""
+
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+from typing import Dict, Iterator, List
+
+from dotenv import load_dotenv
+
+# ----------------------------------------------------------------------
+# 读取 .env 的位置
+#   1) 优先读本项目根目录的 .env(下完代码复制 .env.example 就在这里)
+#   2) 本项目目录没有,再按 dotenv 默认行为从当前目录逐级往上找
+# 先找到先算数,已经存在的环境变量不会被覆盖。
+# ----------------------------------------------------------------------
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+_ENV_FILE = PROJECT_ROOT / ".env"
+
+if _ENV_FILE.exists():
+    load_dotenv(_ENV_FILE)
+else:
+    load_dotenv()
+
+
+class LLMError(RuntimeError):
+    """大模型调用失败时抛这个,方便上层区分"是模型挂了"还是"是代码写错了"。"""
+
+
+class LLM:
+    """一个极简的大模型客户端。
+
+    用法::
+
+        llm = LLM()
+        print(llm.chat([{"role": "user", "content": "你好"}]))
+    """
+
+    def __init__(
+        self,
+        model: str | None = None,
+        api_key: str | None = None,
+        base_url: str | None = None,
+        timeout: float | None = None,
+        max_retry: int = 2,
+    ) -> None:
+        # 优先用调用方传进来的参数;没传就去 .env 里找
+        self.model = model or os.getenv("LLM_MODEL_ID")
+        self.api_key = api_key or os.getenv("LLM_API_KEY")
+        self.base_url = base_url or os.getenv("LLM_BASE_URL")
+        self.timeout = timeout or float(os.getenv("LLM_TIMEOUT", "60"))
+        self.max_retry = max_retry
+
+        missing = [
+            name
+            for name, value in (
+                ("LLM_MODEL_ID", self.model),
+                ("LLM_API_KEY", self.api_key),
+                ("LLM_BASE_URL", self.base_url),
+            )
+            if not value
+        ]
+        if missing:
+            raise LLMError(
+                "缺少配置:" + "、".join(missing) + "\n"
+                "请把 .env.example 复制成 .env(放在这里即可:"
+                f"{PROJECT_ROOT}),然后填上你自己的模型信息。"
+            )
+
+        try:
+            from openai import OpenAI
+        except ImportError as exc:  # pragma: no cover
+            raise LLMError("没装 openai 库。请先执行:pip install -r requirements.txt") from exc
+
+        self._client = OpenAI(
+            api_key=self.api_key,
+            base_url=self.base_url,
+            timeout=self.timeout,
+        )
+
+    # ------------------------------------------------------------------
+    # 主方法
+    # ------------------------------------------------------------------
+    def chat(
+        self,
+        messages: List[Dict[str, str]],
+        temperature: float = 0.0,
+        max_retry: int | None = None,
+    ) -> str:
+        """发一轮对话,返回模型的完整回复文本。
+
+        失败会自动重试(网络抖动很常见),重试 max_retry 次还不行就抛 LLMError。
+        """
+        retry = self.max_retry if max_retry is None else max_retry
+        last_error: Exception | None = None
+
+        for attempt in range(retry + 1):
+            try:
+                response = self._client.chat.completions.create(
+                    model=self.model,
+                    messages=messages,
+                    temperature=temperature,
+                )
+                if not response.choices:
+                    raise LLMError("模型返回了空结果")
+                return response.choices[0].message.content or ""
+            except Exception as exc:  # noqa: BLE001 - 网络/限流/超时都归到这里
+                last_error = exc
+                if attempt < retry:
+                    wait = 1.5 * (attempt + 1)
+                    print(f"  [提示] 模型调用失败({type(exc).__name__}),{wait:.1f} 秒后重试…")
+                    time.sleep(wait)
+
+        raise LLMError(f"连续 {retry + 1} 次调用模型都失败:{last_error}")
+
+    def stream(
+        self,
+        messages: List[Dict[str, str]],
+        temperature: float = 0.0,
+    ) -> Iterator[str]:
+        """流式输出,一个字一个字地吐,适合做命令行里的打字机效果。
+
+        注意:流式返回的是一堆碎片,要自己拼起来("".join),
+        所以需要"整段文本"来做解析的场景(比如 Agent 解析"行动:")
+        请用 chat(),别用这个。
+        """
+        response = self._client.chat.completions.create(
+            model=self.model,
+            messages=messages,
+            temperature=temperature,
+            stream=True,
+        )
+        for chunk in response:
+            if chunk.choices and chunk.choices[0].delta.content:
+                yield chunk.choices[0].delta.content

+ 78 - 0
Co-creation-projects/dandan693-StowageAgent/src/memory.py

@@ -0,0 +1,78 @@
+"""
+记忆机制 → 让 Agent 不是"聊完就忘"。
+
+这里实现两层记忆:
+
+    短期记忆(工作记忆)  —— 这次对话说过什么。就是 messages 列表,有长度上限,
+                             太长了要丢掉最早的(否则 token 会爆)。
+    长期记忆(笔记)      —— 跨对话要记住的事。写进一个 md 文件,下次还在。
+
+一句话记住区别:短期记忆是"刚才聊到哪了",长期记忆是"这个用户有什么习惯"。
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Dict, List
+
+
+class Memory:
+    def __init__(
+        self,
+        note_path: str | Path | None = None,
+        max_messages: int = 12,
+    ) -> None:
+        #: 短期记忆:一串 {"role": ..., "content": ...}
+        self.messages: List[Dict[str, str]] = []
+        self.max_messages = max_messages
+
+        self.note_path = Path(note_path) if note_path else None
+        #: 长期记忆:一串字符串(每条就是一条笔记)
+        self.notes: List[str] = []
+        if self.note_path and self.note_path.exists():
+            self.notes = [
+                line.strip("- ").strip()
+                for line in self.note_path.read_text(encoding="utf-8").splitlines()
+                if line.strip().startswith("-")
+            ]
+
+    # ------------------------------------------------------------------
+    # 短期记忆
+    # ------------------------------------------------------------------
+    def add(self, role: str, content: str) -> None:
+        """记一轮对话。role 是 'user' 或 'assistant'。"""
+        self.messages.append({"role": role, "content": content})
+        self._forget_old()
+
+    def _forget_old(self) -> None:
+        """超出上限就把最早的对话丢掉。
+
+        这是最粗暴的做法("上下文工程"会做得更聪明:
+        把老对话压缩成摘要,而不是直接扔掉)。但对小项目够用。
+        """
+        if len(self.messages) > self.max_messages:
+            overflow = len(self.messages) - self.max_messages
+            self.messages = self.messages[overflow:]
+
+    def recent(self, n: int | None = None) -> List[Dict[str, str]]:
+        return self.messages[-n:] if n else list(self.messages)
+
+    # ------------------------------------------------------------------
+    # 长期记忆
+    # ------------------------------------------------------------------
+    def add_note(self, text: str) -> None:
+        """记一条长期笔记,并立刻落盘。"""
+        self.notes.append(text)
+        if self.note_path:
+            self.note_path.parent.mkdir(parents=True, exist_ok=True)
+            body = "# 配载智能体 · 长期笔记\n\n" + "\n".join(f"- {n}" for n in self.notes) + "\n"
+            self.note_path.write_text(body, encoding="utf-8")
+
+    def recall(self, keyword: str = "") -> List[str]:
+        """按关键词翻笔记;不给关键词就返回全部。"""
+        if not keyword:
+            return list(self.notes)
+        return [n for n in self.notes if keyword in n]
+
+    def clear(self) -> None:
+        self.messages.clear()

+ 110 - 0
Co-creation-projects/dandan693-StowageAgent/src/rag.py

@@ -0,0 +1,110 @@
+"""
+RAG(检索增强生成)→ 把知识库塞进大模型的脑子。
+
+一句话理解 RAG:**开卷考试**。
+不给资料,模型只能凭记忆瞎答(容易编);先翻资料再答,就靠谱多了。
+
+RAG 一共四步,本文件每一步都单独写成了一个小方法,方便你看清楚:
+
+    ① 分块 chunk()      —— 把一大篇资料切成一段一段(本项目的切法极简:一个空行 = 一段)
+    ② 建索引 index()    —— 给每一段算一个"指纹"
+    ③ 检索 search()     —— 把问题也算成指纹,比一比谁最像
+    ④ 拼上下文          —— 由 context.py 负责,把命中的资料贴到问题前面
+
+两种"指纹"算法:
+
+    tfidf  —— 数"字"出现得多不多。不用下载模型、秒开(关键词匹配)
+    vector —— 把整段话压成一串数字,比距离。能看懂"意思"而不只是字面,
+              (语义向量)。首次要下载约 400MB 模型。
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import List, Tuple
+
+
+class Retriever:
+    """知识库检索器。"""
+
+    def __init__(self, kb_path: str | Path, mode: str = "tfidf") -> None:
+        self.kb_path = Path(kb_path)
+        self.mode = mode
+        self.chunks: List[str] = []
+        self._vectors = None  # tfidf 模式下是一个矩阵;vector 模式下是 numpy 数组
+        self._model = None
+
+        if not self.kb_path.exists():
+            raise FileNotFoundError(f"找不到知识库文件:{self.kb_path}")
+
+        self.chunks = self.chunk(self.kb_path.read_text(encoding="utf-8"))
+        if not self.chunks:
+            raise ValueError(f"知识库 {self.kb_path} 是空的")
+
+    # ------------------------------------------------------------------
+    # 第一步:分块
+    # ------------------------------------------------------------------
+    @staticmethod
+    def chunk(text: str) -> List[str]:
+        """把整篇资料切成一段一段。
+
+        真实项目里分块是很讲究的(按标题切、按字数切、带重叠……),
+        这里用最朴素的办法:**一个空行隔开的就是一段**。
+        好处是:你想让机器人多懂一件事,只要在 txt 里加一段就行,不用改代码。
+        """
+        blocks = [b.strip() for b in text.replace("\r\n", "\n").split("\n\n")]
+        return [b for b in blocks if b and not b.startswith("#")]
+
+    # ------------------------------------------------------------------
+    # 第二步:建索引
+    # ------------------------------------------------------------------
+    def index(self) -> None:
+        if self.mode == "tfidf":
+            self._build_tfidf()
+        elif self.mode == "vector":
+            self._build_vector()
+        else:
+            raise ValueError(f"不认识的检索模式:{self.mode}(只支持 tfidf / vector)")
+
+    def _build_tfidf(self) -> None:
+        from sklearn.feature_extraction.text import TfidfVectorizer
+
+        self._vectorizer = TfidfVectorizer(
+            analyzer="char",  # 中文没有空格,按"字"切比按"词"切省事且够用
+            ngram_range=(1, 2),  # 兼顾单字和两字词
+        )
+        self._vectors = self._vectorizer.fit_transform(self.chunks)
+
+    def _build_vector(self) -> None:
+        import os
+
+        # 走国内镜像,否则下模型会卡住
+        os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
+        from sentence_transformers import SentenceTransformer
+
+        # 中文任务一定要用中文模型。用多语言通用模型的话,
+        # "配载图上哪些东西不是箱位"这种问题会被答成别的(实测踩过坑)
+        self._model = SentenceTransformer("shibing624/text2vec-base-chinese")
+        self._vectors = self._model.encode(self.chunks, normalize_embeddings=True)
+
+    # ------------------------------------------------------------------
+    # 第三步:检索
+    # ------------------------------------------------------------------
+    def search(self, query: str, top_k: int = 3) -> List[Tuple[str, float]]:
+        """返回 [(资料段落, 相关度分数), …],分数越高越相关。"""
+        if self._vectors is None:
+            self.index()
+
+        if self.mode == "tfidf":
+            from sklearn.metrics.pairwise import cosine_similarity
+
+            q = self._vectorizer.transform([query])
+            scores = cosine_similarity(q, self._vectors)[0]
+        else:
+            q = self._model.encode([query], normalize_embeddings=True)[0]
+            scores = self._vectors @ q
+
+        import numpy as np
+
+        order = np.argsort(scores)[::-1][:top_k]
+        return [(self.chunks[i], float(scores[i])) for i in order if scores[i] > 0]

+ 308 - 0
Co-creation-projects/dandan693-StowageAgent/src/tools.py

@@ -0,0 +1,308 @@
+"""
+工具系统 → 这是全项目最值钱的部分。
+
+工具就是"智能体的手"。大模型只会写字,不会算数、不会查表。
+你把一个能力包装成工具,它就能调用。
+
+工具的三件套(缺一不可):
+    name         名字     —— 大模型用它来"点名"
+    description  说明书   —— 大模型靠这段文字决定"什么时候该用它"
+    run()        干活的   —— 真正的逻辑,输入文本、输出文本
+
+⚠️ 关键认知:大模型看不见 run() 里的代码,它只看 description。
+   所以 description 写得好不好,直接决定工具会不会被用对。
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Dict, List, Tuple
+
+# ======================================================================
+# 底层规则 —— 这些是集装箱配载的行业知识,跟 AI 无关
+# ======================================================================
+
+# 40 尺箱占两个相邻的 20 尺小贝位,图纸上把中间那个偶数号叫"大贝"
+# 例:01 + 03 → 02 ;05 + 07 → 06 ;09 + 11 → 10
+PAIR_MAP: Dict[int, int] = {1: 2, 5: 6, 9: 10, 13: 14, 17: 18, 21: 22, 25: 26}
+REVERSE_PAIR_MAP: Dict[int, Tuple[int, int]] = {v: (k, k + 2) for k, v in PAIR_MAP.items()}
+
+# 层号:02–08 在船舱里;82 及以上(82/84/86/88)在甲板上
+HOLD_TIER_MAX = 8  # 舱内层号上限(含)
+DECK_TIER_MIN = 82  # 舱面层号下限(含)
+
+
+def merge_bay(small_bay: int) -> int:
+    """小贝号 → 大贝号。
+
+    奇数小贝:01→02、05→06、09→10(两个小贝合成一个大贝)
+    已经是偶数(本来就是大贝号):原样返回
+    """
+    if small_bay % 2 == 0:
+        return small_bay
+    if small_bay in PAIR_MAP:
+        return PAIR_MAP[small_bay]
+    # 表里没列的,按"大贝 = 前一个小贝 + 1"的规律推
+    return small_bay + 1
+
+
+def expand_bay(big_bay: int) -> Tuple[int, int]:
+    """大贝号 → 组成它的两个小贝号。例:02 → (01, 03)"""
+    if big_bay % 2 != 0:
+        return (big_bay, big_bay)  # 奇数本来就是小贝,拆不出两个
+    if big_bay in REVERSE_PAIR_MAP:
+        return REVERSE_PAIR_MAP[big_bay]
+    return (big_bay - 1, big_bay + 1)
+
+
+def tier_location(tier: int) -> str:
+    """层号 → 这个箱子在船上哪个位置。"""
+    if 1 <= tier <= HOLD_TIER_MAX:
+        return "舱内"
+    if tier >= DECK_TIER_MIN:
+        return "舱面(甲板)"
+    return "未知(不在常见层号区间)"
+
+
+def format_coord(bay: int, pos: int, tier: int) -> str:
+    """三个数字 → 六位坐标,例如 (1, 6, 82) → '010682'。
+
+    ⚠️ 一定要补前导零!02 写成 2,坐标就废了。
+    """
+    return f"{bay:02d}{pos:02d}{tier:02d}"
+
+
+def parse_coord(coord: str) -> Tuple[int, int, int]:
+    """六位坐标 → (贝位, 排位, 层号),例如 '010682' → (1, 6, 82)。"""
+    text = coord.strip()
+    if not re.fullmatch(r"\d{6}", text):
+        raise ValueError(f"'{coord}' 不是合法的六位坐标(应为 6 位数字,如 010682)")
+    return int(text[0:2]), int(text[2:4]), int(text[4:6])
+
+
+# ======================================================================
+# 工具基类
+# ======================================================================
+class BaseTool:
+    name: str = "tool"
+    description: str = "工具说明"
+
+    def run(self, query: str) -> str:  # pragma: no cover - 由子类实现
+        raise NotImplementedError
+
+
+# ======================================================================
+# 工具 1:贝位计算器
+# ======================================================================
+class BayTool(BaseTool):
+    name = "贝位计算器"
+    description = (
+        "计算集装箱船贝位(Bay)。"
+        "输入形如 '01+03' 或 '01,03' 的两个小贝,返回合成后的大贝号(40尺箱用);"
+        "输入单个偶数大贝,如 '02',返回它的两个小贝号;"
+        "输入单个奇数小贝,如 '05',原样返回(20尺箱写自己的小贝号)。"
+    )
+
+    def run(self, query: str) -> str:
+        text = query.strip().replace(",", ",").replace(" ", "")
+        if not text:
+            return "错误:没有输入贝位"
+
+        # 情况一:两个小贝相加
+        if "+" in text or "," in text:
+            parts = re.split(r"[+,]", text)
+            nums = [int(p) for p in parts if p.strip().isdigit()]
+            if len(nums) != 2:
+                return f"错误:应该给两个小贝号,例如 '01+03',你给的是 '{query}'"
+            a, b = sorted(nums)
+            if a % 2 == 0 or b % 2 == 0:
+                return f"错误:{a:02d} 和 {b:02d} 里有偶数。成对的小贝必须是两个奇数(如 01+03)"
+            if b - a != 2:
+                return f"错误:{a:02d} 和 {b:02d} 不相邻,不是一对小贝(相邻的两个奇数才成对)"
+            return (
+                f"{a:02d} + {b:02d} → 大贝 {merge_bay(a):02d}\n"
+                f"说明:40尺箱写大贝号 {merge_bay(a):02d};20尺箱写各自的小贝号 {a:02d} / {b:02d}"
+            )
+
+        # 情况二:单个贝位
+        if not text.isdigit():
+            return f"错误:'{query}' 不是数字"
+        num = int(text)
+        if num % 2 == 0:
+            left, right = expand_bay(num)
+            return (
+                f"大贝 {num:02d} → 由小贝 {left:02d} 和 {right:02d} 合成\n"
+                f"说明:这个贝位上的 40 尺箱写 {num:02d},20 尺箱分别写 {left:02d} / {right:02d}"
+            )
+        return (
+            f"{num:02d} 是小贝号(奇数),保持原样\n"
+            f"说明:20 尺箱写小贝号 {num:02d};如果这里装 40 尺箱,应写成大贝号 {merge_bay(num):02d}"
+        )
+
+
+# ======================================================================
+# 工具 2:坐标解析器
+# ======================================================================
+class CoordTool(BaseTool):
+    name = "坐标解析器"
+    description = (
+        "处理六位箱位坐标(贝位2位 + 排位2位 + 层号2位)。"
+        "输入六位数字如 '010682',返回它拆开的贝位、排位、层号,以及箱子在舱内还是甲板上;"
+        "也可以输入三个用逗号隔开的数字如 '1,6,82' 来反向拼出六位坐标。"
+    )
+
+    def run(self, query: str) -> str:
+        text = query.strip().replace(",", ",").replace(" ", "")
+
+        # 反向:三个数字 → 六位码
+        if "," in text:
+            parts = text.split(",")
+            if len(parts) != 3 or not all(p.isdigit() for p in parts):
+                return f"错误:应该给三个数字,例如 '1,6,82',你给的是 '{query}'"
+            bay, pos, tier = (int(p) for p in parts)
+            return (
+                f"贝位{bay:02d} + 排位{pos:02d} + 层号{tier:02d} → 坐标 {format_coord(bay, pos, tier)}\n"
+                f"位置:{tier_location(tier)}"
+            )
+
+        # 正向:六位码 → 三要素
+        try:
+            bay, pos, tier = parse_coord(text)
+        except ValueError as exc:
+            return f"错误:{exc}"
+
+        side = ""
+        if pos % 2 == 1:
+            side = "(奇数排位,通常在中线右侧 / 右舷方向)"
+        elif pos != 0:
+            side = "(偶数排位,通常在中线左侧 / 左舷方向)"
+
+        return (
+            f"坐标 {text} 拆解:\n"
+            f"  贝位 Bay = {bay:02d}\n"
+            f"  排位 Pos = {pos:02d}{side}\n"
+            f"  层号 Tier = {tier:02d} → {tier_location(tier)}"
+        )
+
+
+# ======================================================================
+# 工具 3:箱位校验器(这条最像"测试")
+# ======================================================================
+class StowageCheckTool(BaseTool):
+    name = "箱位校验器"
+    description = (
+        "校验一批箱位坐标是否合规。"
+        "输入多行或逗号隔开的坐标(可写成 'HE 010682' 带箱型,也可只写 '010682'),"
+        "返回:格式错误、重复坐标、箱型与贝位是否匹配(40尺必须是偶数大贝、20尺必须是奇数小贝)、"
+        "以及各箱型的数量统计。"
+    )
+
+    #: 看名字就能判断尺寸的箱型(大写=40尺,含小写字母=20尺)
+    @staticmethod
+    def _size_of(mark: str) -> str:
+        if not mark:
+            return "未知"
+        if mark.endswith("+") or mark in {"HE", "X", "F", "NE", "*"}:
+            return "40尺"
+        if mark.isupper():
+            return "40尺"
+        return "20尺"
+
+    def run(self, query: str) -> str:
+        rows: List[Tuple[str, str]] = []
+        for raw in re.split(r"[\n,,;;]+", query):
+            item = raw.strip()
+            if not item:
+                continue
+            m = re.search(r"(\d{6})", item)
+            if not m:
+                rows.append(("", item))
+                continue
+            mark = item[: m.start()].strip()
+            rows.append((mark, m.group(1)))
+
+        if not rows:
+            return "错误:没有解析到任何坐标"
+
+        errors: List[str] = []
+        marks: Dict[str, int] = {}
+        seen: Dict[str, int] = {}
+        for idx, (mark, coord) in enumerate(rows, 1):
+            if not coord:
+                errors.append(f"第 {idx} 行 '{mark}' 里找不到六位坐标")
+                continue
+            seen[coord] = seen.get(coord, 0) + 1
+            bay, pos, tier = parse_coord(coord)
+            size = self._size_of(mark)
+            if size == "40尺" and bay % 2 != 0:
+                errors.append(f"'{mark} {coord}':40尺箱应写偶数大贝号,但贝位是 {bay:02d}")
+            if size == "20尺" and bay % 2 == 0:
+                errors.append(f"'{mark} {coord}':20尺箱应写奇数小贝号,但贝位是 {bay:02d}")
+            if not (1 <= tier <= HOLD_TIER_MAX or tier >= DECK_TIER_MIN):
+                errors.append(f"'{coord}':层号 {tier:02d} 不在常见区间(舱内 02-08 / 舱面 82 以上)")
+            key = mark or "(未标注箱型)"
+            marks[key] = marks.get(key, 0) + 1
+
+        dup = [c for c, n in seen.items() if n > 1]
+
+        lines = [f"共收到 {len(rows)} 个箱位,去重后 {len(seen)} 个"]
+        lines.append("箱型统计:" + "  ".join(f"{k}={v}" for k, v in sorted(marks.items())))
+        if dup:
+            lines.append("⚠️ 重复坐标:" + "、".join(sorted(dup)))
+        if errors:
+            lines.append(f"⚠️ 发现 {len(errors)} 处问题:")
+            lines.extend("  - " + e for e in errors[:20])
+        else:
+            lines.append("✅ 未发现格式或贝位问题")
+        return "\n".join(lines)
+
+
+# ======================================================================
+# 工具 4:知识库检索(内部调 RAG)
+# ======================================================================
+class KnowledgeTool(BaseTool):
+    name = "知识库检索"
+    description = (
+        "在配载规则知识库里查资料。"
+        "当你需要确认某条业务规则(贝位怎么合成、层号怎么分舱内舱面、"
+        "箱型标记怎么写、图纸有哪几种排版、坐标为什么要补前导零等)时使用。"
+        "输入一段自然语言问题,返回最相关的几段规则原文。"
+    )
+
+    def __init__(self, retriever, top_k: int = 3) -> None:
+        self.retriever = retriever
+        self.top_k = top_k
+
+    def run(self, query: str) -> str:
+        hits = self.retriever.search(query, top_k=self.top_k)
+        if not hits:
+            return "知识库里没有找到相关内容。"
+        parts = []
+        for i, (text, score) in enumerate(hits, 1):
+            parts.append(f"【资料{i}|相关度 {score:.3f}】\n{text}")
+        return "\n\n".join(parts)
+
+
+# ======================================================================
+# 工具注册表
+# ======================================================================
+class ToolRegistry:
+    """统一管理所有工具:注册、按名字取用、生成给大模型看的说明书。"""
+
+    def __init__(self) -> None:
+        self._tools: Dict[str, BaseTool] = {}
+
+    def register(self, tool: BaseTool) -> "ToolRegistry":
+        self._tools[tool.name] = tool
+        return self
+
+    def get(self, name: str) -> BaseTool | None:
+        return self._tools.get(name.strip())
+
+    @property
+    def names(self) -> List[str]:
+        return list(self._tools)
+
+    def describe(self) -> str:
+        """拼出工具清单,这段文本会被塞进系统提示词里。"""
+        return "\n".join(f"- {t.name}:{t.description}" for t in self._tools.values())