|
|
há 1 semana atrás | |
|---|---|---|
| .. | ||
| data | há 5 dias atrás | |
| outputs | há 5 dias atrás | |
| src | há 5 dias atrás | |
| .env.example | há 5 dias atrás | |
| .gitignore | há 5 dias atrás | |
| LICENSE | há 5 dias atrás | |
| README.md | há 5 dias atrás | |
| main.ipynb | há 5 dias atrás | |
| main.py | há 5 dias atrás | |
| requirements.txt | há 5 dias atrás | |
一个会查配载规则、会算贝位坐标的集装箱船配载助手。 纯 Python 手写 ReAct 循环,不依赖任何 Agent 框架,装 4 个包就能跑。
StowageAgent 是一个面向集装箱船配载场景的智能体。它要解决的是配载作业里最重复、最容易出错的两件事:
本项目把一个智能体接进这两件事,它能够:
本项目为纯 Python 实现:命令行入口 main.py + 教学版 Notebook main.ipynb,没有前端页面,
也不依赖任何 Agent 框架 —— 只用 openai 一个 SDK 手写 ReAct 循环。
| 模块 | 职责 |
|---|---|
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 个文件读完,骨架就清楚了。
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
src/agent.py 全部 208 行,能一行不跳地读完tfidf(关键词)和 vector(语义向量),可对比效果。chat 模式能记住前面说过的话)+ 长期笔记文件。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 运行中自己记的笔记
openai SDK —— 任何兼容 OpenAI 接口的服务都能用(DeepSeek / 通义 / 智谱 / 本地 vLLM)scikit-learn 的 TF-IDF(默认);可选 sentence-transformers 做语义向量python-dotenv 读 .env为什么不直接用现成的 Agent 框架?
先手写一遍,才知道框架替你干了什么。
项目里的模块划分(llm / tools / memory / context / agent / evaluate)和主流框架的设计是一一对应的。
pip install -r requirements.txt
需要联网,会装 openai / python-dotenv / scikit-learn / numpy 四个包,一分钟以内。
# Windows
copy .env.example .env
# macOS / Linux
cp .env.example .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.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 # 两层都跑
jupyter lab main.ipynb
main.ipynb 是教学版:每一步单独一格,从连大模型到跑评估,按顺序点下来就把 7 章串起来了。
--no-llm 参数,只用检索、不调用大模型,一分钱不花也能跑通。ModuleNotFoundError: No module named 'src'? 请确认你在项目根目录(能看到 main.py 的那一层)执行命令。main.py 已经做了路径兜底,从项目根跑最稳。.env 不生效? 环境变量在进程启动时读入,改完 .env 需要重启内核(或重新跑一遍加载单元格)。命令行则每次运行都会重新读,不受影响。pip 报错(Fatal error in launcher / 找不到 pip)? 改用 python -m pip install -r requirements.txt,这是最通用的写法。--mode vector,但需要先取消 requirements.txt 里 sentence-transformers 那行的注释,首次运行还会下载约 400MB 的中文向量模型。建议先用默认的 tfidf 把流程跑通。$env:PYTHONIOENCODING="utf-8",CMD 执行 chcp 65001。.env 里 LLM_BASE_URL 结尾是否带 /v1(不同服务商要求不同),以及 LLM_MODEL_ID 是否服务商真实支持的模型名。python main.py ask "贝位 09 和 11 合成的大贝是几号?层号 84 的箱子在甲板上还是船舱里?" --show-steps
实际运行过程(这就是 ReAct 的「思考轨迹」):
── 第 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 的箱子在甲板上(舱面)。
注意它做了两件普通聊天机器人做不到的事: 自己选了正确的工具,而且数字是从工具拿的,不是自己算的。
python main.py ask "宁波到洛杉矶一个 40 尺柜的运费是多少?"
预期回答会包含「没有提到」。这条在 data/测试用例.json 里是专门的测试用例,
因为「不瞎编」是 RAG 最核心的价值,必须测。
python main.py ask "坐标的前导零丢了会怎样" --no-llm
会把实际发给大模型的完整提示词打印出来。你会发现 RAG 说穿了就是 「自动帮你把资料粘到问题前面」—— 不神秘。
from src import StowageCheckTool
print(StowageCheckTool().run("""
HE 010682
f 010304
NE 040406
NE 040406
"""))
输出会指出:HE 010682 的 40 尺箱写了奇数贝位、NE 040406 重复了。
src/ 下 7 个模块各管一件事,上面有对照表,不用在几千行代码里找「那个功能在哪」。src/agent.py 一共 208 行,其中真正驱动循环的不到 50 行,Thought / Action / Observation 全都打印出来给你看。data/配载知识库.txt 里 21 段规则全部来自真实的配载作业经验(贝位合成规则、不同图纸排版、不同船的占位符号差异、前导零的坑……),这也是它和网上那些 RAG demo 最大的区别。pip install 4 个包就能跑:可复现性最好,不会因为某个框架升级就挂掉;想换成现成的 Agent 框架,改 src/agent.py 一个文件就行。python main.py eval --offline
| 指标 | 结果 |
|---|---|
| 用例数 | 15 |
| 通过 | 15 |
| 通过率 | 100% |
覆盖范围:贝位合成(含非法配对报错)、大贝拆小贝、坐标拆解、舱内 / 甲板判定、 前导零补位、六位码格式校验、40 尺箱贝位奇偶校验、重复坐标检测、知识库检索命中。
这一层不调用大模型,所以结果完全确定、可重复,适合放进 CI。
python main.py eval --online
| 指标 | 结果 |
|---|---|
| 用例数 | 12 |
| 通过 | 12 |
| 准确率 | 100% |
| 平均耗时 | 12.5 秒 / 题 |
| 分类 | 用例数 | 准确率 |
|---|---|---|
| 规则问答 | 6 | 100% |
| 会用工具 | 4 | 100% |
| 边界情况(不该瞎编) | 2 | 100% |
真实运行片段(python main.py ask "..." --show-steps):
── 第 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 被「只准根据资料回答」这条提示词勒住, 宁可说不知道 —— 这也是抑制「模型幻觉」最有效的做法之一。
这两次都发生在同一天、同一个项目上。它们说明:跑评估最有价值的产出不是分数,而是「发现问题」。
第一轮跑出来 11/12,挂掉的是这一题:
读 .xls 和 .xlsx 分别要用什么 Python 库?
答案在知识库里明明有,但 Agent 一步工具都没调(步数 = 0),直接说「资料里没有提到」。
改法(src/agent.py 的 system prompt):
3. 凡是涉及配载业务规则、图纸规则、文件读写约定这类问题,不管你自己觉不觉得知道答案,
都必须先调用【知识库检索】把资料拿到手,再回答。
没检索过就直接说"资料里没有提到",是错的。
复测:该用例从「❌ 步数 0」变成「✅ 步数 1」,全量 12/12。
重跑一遍时又出现 11/12,挂掉的是:
帮我检查一下这两个箱位对不对:HE 010682 和 f 010304
但这次 Agent 的回答完全正确:
HE 010682 不对:HE 表示 40 尺箱,应写偶数大贝号,但 01 是奇数小贝(资料1、2)。
f 010304 可以:f 表示 20 尺重箱,应写奇数小贝号,01 符合(资料2)。
问题出在断言本身:测试用例要求回答里必须出现 40尺,而模型写的是 40 尺
—— 数字和单位之间多了一个空格,字符串精确匹配就失败了。
40 尺箱、0.4 秒),
断言死抠字面必然误报。改法(src/evaluate.py):加一个 _normalize(),比对前把空白字符全部去掉。
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 尺箱有多少个」欢迎提 Issue 和 Pull Request!
data/配载知识库.txt,一个空行加一段,不用改任何代码,跑一次 --no-llm 就知道有没有被检索到。data/测试用例.json,加完跑 python main.py eval --all 看有没有通过。本项目采用 MIT License,可自由使用、修改、分发。
data/配载知识库.txt里的配载规则来自个人作业经验总结,仅供参考学习; 实际配载作业请以船公司 / 码头的正式规范为准。
感谢 Datawhale 社区与 Hello-Agents 项目提供的开源学习资源与共创平台。